Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc37e6da96 | ||
|
|
d54b70d18a | ||
|
|
c529529f84 | ||
|
|
13bac9c91a | ||
|
|
fe53af4819 | ||
|
|
e82c5a9a28 | ||
|
|
3236f228fb | ||
|
|
0e0e7a4a4b | ||
|
|
10a3d6c54e | ||
|
|
832b8e252e | ||
|
|
040f551c57 | ||
|
|
cc818f8032 | ||
|
|
d5337b41f4 | ||
|
|
9f7a76d6c0 | ||
|
|
6a16db4b92 | ||
|
|
9ad6588f3e | ||
|
|
fb6bf0b35e | ||
|
|
f80343b875 | ||
|
|
9b805e1cc4 | ||
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac |
+3
-1
@@ -10,6 +10,7 @@
|
|||||||
adamdotdevin
|
adamdotdevin
|
||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
@@ -17,8 +18,9 @@ iamdavidhill
|
|||||||
jayair
|
jayair
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
|
-opencode2026
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-OpenCode2026
|
-danieljoshuanazareth
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -336,8 +337,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.87",
|
"@opentui/core": "0.1.88",
|
||||||
"@opentui/solid": "0.1.87",
|
"@opentui/solid": "0.1.88",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -1446,21 +1447,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="],
|
"@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="],
|
"@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
@@ -1966,10 +1967,14 @@
|
|||||||
|
|
||||||
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
|
"@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="],
|
||||||
|
|
||||||
|
"@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="],
|
||||||
|
|
||||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
|
"@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="],
|
||||||
|
|
||||||
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
|
"@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="],
|
||||||
|
|
||||||
|
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||||
@@ -5198,8 +5203,6 @@
|
|||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
|
||||||
|
|
||||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||||
|
|
||||||
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=",
|
"x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=",
|
||||||
"aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=",
|
"aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=",
|
||||||
"aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=",
|
"aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=",
|
||||||
"x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA="
|
"x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { runTerminal, waitTerminalReady } from "../actions"
|
import { runTerminal, waitTerminalReady } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector } from "../selectors"
|
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const rename = `E2E term ${Date.now()}`
|
||||||
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
|
await tab.click({ button: "right" })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0)
|
||||||
|
await expect(tab).toContainText(rename)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return state?.all[0]?.title
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toBe(rename)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
|||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { type Duration, Effect } from "effect"
|
import { type Duration, Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
@@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QueryProvider(props: ParentProps) {
|
||||||
|
const client = new QueryClient()
|
||||||
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
function AppShellProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<DialogProvider>
|
<QueryProvider>
|
||||||
<MarkedProviderWithNativeParser>
|
<DialogProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
</MarkedProviderWithNativeParser>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</DialogProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
|
</DialogProvider>
|
||||||
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export type FormState = {
|
|||||||
apiKey: string
|
apiKey: string
|
||||||
models: ModelRow[]
|
models: ModelRow[]
|
||||||
headers: HeaderRow[]
|
headers: HeaderRow[]
|
||||||
saving: boolean
|
|
||||||
err: {
|
err: {
|
||||||
providerID?: string
|
providerID?: string
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
{ row: "h1", key: "", value: "", err: {} },
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
],
|
],
|
||||||
saving: false,
|
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
|
|||||||
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
{ row: "h1", key: "authorization", value: "two", err: {} },
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
],
|
],
|
||||||
saving: false,
|
|
||||||
err: {},
|
err: {},
|
||||||
},
|
},
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [modelRow()],
|
models: [modelRow()],
|
||||||
headers: [headerRow()],
|
headers: [headerRow()],
|
||||||
saving: false,
|
|
||||||
err: {},
|
err: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if (form.saving) return
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
const result = validate()
|
if (result.key) {
|
||||||
if (!result) return
|
await globalSDK.client.auth.set({
|
||||||
|
|
||||||
setForm("saving", true)
|
|
||||||
|
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
const auth = result.key
|
|
||||||
? globalSDK.client.auth.set({
|
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve()
|
}
|
||||||
|
|
||||||
auth
|
await globalSync.updateConfig({
|
||||||
.then(() =>
|
provider: { [result.providerID]: result.config },
|
||||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
disabled_providers: nextDisabled,
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
dialog.close()
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
return result
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
},
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
onSuccess: (result) => {
|
||||||
})
|
dialog.close()
|
||||||
.finally(() => {
|
showToast({
|
||||||
setForm("saving", false)
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const save = (e: SubmitEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
|
||||||
|
const result = validate()
|
||||||
|
if (!result) return
|
||||||
|
saveMutation.mutate(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
<Button
|
||||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
class="w-auto self-start"
|
||||||
|
type="submit"
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
color: props.project.icon?.color || "pink",
|
color: props.project.icon?.color || "pink",
|
||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
saving: false,
|
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
setStore("iconUrl", "")
|
setStore("iconUrl", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
await Promise.resolve()
|
if (props.project.id && props.project.id !== "global") {
|
||||||
.then(async () => {
|
await globalSDK.client.project.update({
|
||||||
setStore("saving", true)
|
projectID: props.project.id,
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
directory: props.project.worktree,
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await globalSDK.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start: start || undefined },
|
commands: { start },
|
||||||
})
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
})
|
})
|
||||||
.finally(() => {
|
dialog.close()
|
||||||
setStore("saving", false)
|
},
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
saveMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = async (name: string) => {
|
const toggle = useMutation(() => ({
|
||||||
if (loading()) return
|
mutationFn: async (name: string) => {
|
||||||
setLoading(name)
|
|
||||||
try {
|
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
} finally {
|
},
|
||||||
setLoading(null)
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) toggle(x.name)
|
if (!x || toggle.isPending) return
|
||||||
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
<Switch
|
||||||
|
checked={enabled()}
|
||||||
|
disabled={toggle.isPending && toggle.variables === i.name}
|
||||||
|
onChange={() => {
|
||||||
|
if (toggle.isPending) return
|
||||||
|
toggle.mutate(i.name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
busy: false,
|
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
|
|||||||
name: "",
|
name: "",
|
||||||
username: DEFAULT_USERNAME,
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
await select(conn, true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const editMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||||
|
if (input.original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(input.value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = input.original.displayName
|
||||||
|
if (
|
||||||
|
normalized === input.original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === input.original.http.username &&
|
||||||
|
password === input.original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === input.original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(input.original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
@@ -296,7 +362,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { name: value, error: "" })
|
setStore("addServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { name: value, error: "" })
|
setStore("editServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(value: string) {
|
|
||||||
if (store.addServer.adding) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
setStore("addServer", { adding: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
await select(conn, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
|
||||||
if (store.editServer.busy || original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = original.displayName
|
|
||||||
if (
|
|
||||||
normalized === original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === original.http.username &&
|
|
||||||
password === original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http)
|
|
||||||
setStore("editServer", { busy: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
|
|||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
void handleAdd(store.addServer.url)
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { error: "" })
|
||||||
|
addMutation.mutate(store.addServer.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const original = editing()
|
const original = editing()
|
||||||
if (!original) return
|
if (!original) return
|
||||||
void handleEdit(original, store.editServer.value)
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { error: "" })
|
||||||
|
editMutation.mutate({ original, value: store.editServer.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
})
|
})
|
||||||
let input: HTMLInputElement | undefined
|
let input: HTMLInputElement | undefined
|
||||||
let blurFrame: number | undefined
|
let blurFrame: number | undefined
|
||||||
|
let editRequested = false
|
||||||
|
|
||||||
const isDefaultTitle = () => {
|
const isDefaultTitle = () => {
|
||||||
const number = props.terminal.titleNumber
|
const number = props.terminal.titleNumber
|
||||||
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
left: `${store.menuPosition.x}px`,
|
left: `${store.menuPosition.x}px`,
|
||||||
top: `${store.menuPosition.y}px`,
|
top: `${store.menuPosition.y}px`,
|
||||||
}}
|
}}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
if (!editRequested) return
|
||||||
|
e.preventDefault()
|
||||||
|
editRequested = false
|
||||||
|
requestAnimationFrame(() => edit())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item onSelect={edit}>
|
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||||
{language.t("common.rename")}
|
{language.t("common.rename")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -130,41 +131,30 @@ const useDefaultServerKey = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggle = (input: {
|
const useMcpToggleMutation = () => {
|
||||||
sync: ReturnType<typeof useSync>
|
const sync = useSync()
|
||||||
sdk: ReturnType<typeof useSDK>
|
const sdk = useSDK()
|
||||||
language: ReturnType<typeof useLanguage>
|
const language = useLanguage()
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const toggle = async (name: string) => {
|
return useMutation(() => ({
|
||||||
if (loading()) return
|
mutationFn: async (name: string) => {
|
||||||
setLoading(name)
|
const status = sync.data.mcp[name]
|
||||||
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
try {
|
const result = await sdk.client.mcp.status()
|
||||||
const status = input.sync.data.mcp[name]
|
if (result.data) sync.set("mcp", result.data)
|
||||||
await (status?.status === "connected"
|
},
|
||||||
? input.sdk.client.mcp.disconnect({ name })
|
onError: (err) => {
|
||||||
: input.sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await input.sdk.client.mcp.status()
|
|
||||||
if (result.data) input.sync.set("mcp", result.data)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : String(err),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
} finally {
|
},
|
||||||
setLoading(null)
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { loading, toggle }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -181,7 +171,7 @@ export function StatusPopover() {
|
|||||||
})
|
})
|
||||||
const health = useServerHealth(servers)
|
const health = useServerHealth(servers)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const mcp = useMcpToggle({ sync, sdk, language })
|
const toggleMcp = useMcpToggleMutation()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
@@ -337,8 +327,11 @@ export function StatusPopover() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
onClick={() => mcp.toggle(name)}
|
onClick={() => {
|
||||||
disabled={mcp.loading() === name}
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -354,8 +347,11 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={mcp.loading() === name}
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
onChange={() => mcp.toggle(name)}
|
onChange={() => {
|
||||||
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const dict = {
|
|||||||
|
|
||||||
"command.sidebar.toggle": "Toggle sidebar",
|
"command.sidebar.toggle": "Toggle sidebar",
|
||||||
"command.project.open": "Open project",
|
"command.project.open": "Open project",
|
||||||
|
"command.project.previous": "Previous project",
|
||||||
|
"command.project.next": "Next project",
|
||||||
"command.provider.connect": "Connect provider",
|
"command.provider.connect": "Connect provider",
|
||||||
"command.server.switch": "Switch server",
|
"command.server.switch": "Switch server",
|
||||||
"command.settings.open": "Open settings",
|
"command.settings.open": "Open settings",
|
||||||
|
|||||||
@@ -936,6 +936,26 @@ export default function Layout(props: ParentProps) {
|
|||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateProjectByOffset(offset: number) {
|
||||||
|
const projects = layout.projects.list()
|
||||||
|
if (projects.length === 0) return
|
||||||
|
|
||||||
|
const current = currentProject()?.worktree
|
||||||
|
const fallback = currentDir() ? projectRoot(currentDir()) : undefined
|
||||||
|
const active = current ?? fallback
|
||||||
|
const index = active ? projects.findIndex((project) => project.worktree === active) : -1
|
||||||
|
|
||||||
|
const target =
|
||||||
|
index === -1
|
||||||
|
? offset > 0
|
||||||
|
? projects[0]
|
||||||
|
: projects[projects.length - 1]
|
||||||
|
: projects[(index + offset + projects.length) % projects.length]
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
openProject(target.worktree)
|
||||||
|
}
|
||||||
|
|
||||||
function navigateSessionByUnseen(offset: number) {
|
function navigateSessionByUnseen(offset: number) {
|
||||||
const sessions = currentSessions()
|
const sessions = currentSessions()
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
@@ -1002,6 +1022,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
keybind: "mod+o",
|
keybind: "mod+o",
|
||||||
onSelect: () => chooseProject(),
|
onSelect: () => chooseProject(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "project.previous",
|
||||||
|
title: language.t("command.project.previous"),
|
||||||
|
category: language.t("command.category.project"),
|
||||||
|
keybind: "mod+alt+arrowup",
|
||||||
|
onSelect: () => navigateProjectByOffset(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project.next",
|
||||||
|
title: language.t("command.project.next"),
|
||||||
|
category: language.t("command.category.project"),
|
||||||
|
keybind: "mod+alt+arrowdown",
|
||||||
|
onSelect: () => navigateProjectByOffset(1),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "provider.connect",
|
id: "provider.connect",
|
||||||
title: language.t("command.provider.connect"),
|
title: language.t("command.provider.connect"),
|
||||||
|
|||||||
+129
-121
@@ -1,5 +1,6 @@
|
|||||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
batch,
|
batch,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -327,10 +328,7 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
git: false,
|
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
restoring: undefined as string | undefined,
|
|
||||||
reverting: false,
|
|
||||||
reviewSnap: false,
|
reviewSnap: false,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
@@ -506,7 +504,6 @@ export default function Page() {
|
|||||||
|
|
||||||
const [followup, setFollowup] = createStore({
|
const [followup, setFollowup] = createStore({
|
||||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||||
sending: {} as Record<string, string | undefined>,
|
|
||||||
failed: {} as Record<string, string | undefined>,
|
failed: {} as Record<string, string | undefined>,
|
||||||
paused: {} as Record<string, boolean | undefined>,
|
paused: {} as Record<string, boolean | undefined>,
|
||||||
edit: {} as Record<
|
edit: {} as Record<
|
||||||
@@ -644,25 +641,24 @@ export default function Page() {
|
|||||||
globalSync.set("project", [...list, next])
|
globalSync.set("project", [...list, next])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitMutation = useMutation(() => ({
|
||||||
|
mutationFn: () => sdk.client.project.initGit(),
|
||||||
|
onSuccess: (x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
function initGit() {
|
function initGit() {
|
||||||
if (ui.git) return
|
if (gitMutation.isPending) return
|
||||||
setUi("git", true)
|
gitMutation.mutate()
|
||||||
void sdk.client.project
|
|
||||||
.initGit()
|
|
||||||
.then((x) => {
|
|
||||||
if (!x.data) return
|
|
||||||
upsert(x.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
variant: "error",
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: formatServerError(err, language.t),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setUi("git", false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
@@ -961,8 +957,8 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||||
{ui.git
|
{gitMutation.isPending
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1379,10 +1375,40 @@ export default function Page() {
|
|||||||
return followup.edit[id]
|
return followup.edit[id]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const followupMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||||
|
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
||||||
|
setFollowup("failed", input.sessionID, undefined)
|
||||||
|
|
||||||
|
const ok = await sendFollowupDraft({
|
||||||
|
client: sdk.client,
|
||||||
|
sync,
|
||||||
|
globalSync,
|
||||||
|
draft: item,
|
||||||
|
optimisticBusy: item.sessionDirectory === sdk.directory,
|
||||||
|
}).catch((err) => {
|
||||||
|
setFollowup("failed", input.sessionID, input.id)
|
||||||
|
fail(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||||
|
if (input.manual) resumeScroll()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const followupBusy = (sessionID: string) =>
|
||||||
|
followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
|
||||||
|
|
||||||
const sendingFollowup = createMemo(() => {
|
const sendingFollowup = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
return followup.sending[id]
|
if (!followupBusy(id)) return
|
||||||
|
return followupMutation.variables?.id
|
||||||
})
|
})
|
||||||
|
|
||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
@@ -1422,37 +1448,15 @@ export default function Page() {
|
|||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followup.sending[sessionID]) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
|
|
||||||
if (opts?.manual) setFollowup("paused", sessionID, undefined)
|
return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
|
||||||
setFollowup("sending", sessionID, id)
|
|
||||||
setFollowup("failed", sessionID, undefined)
|
|
||||||
|
|
||||||
return sendFollowupDraft({
|
|
||||||
client: sdk.client,
|
|
||||||
sync,
|
|
||||||
globalSync,
|
|
||||||
draft: item,
|
|
||||||
optimisticBusy: item.sessionDirectory === sdk.directory,
|
|
||||||
})
|
|
||||||
.then((ok) => {
|
|
||||||
if (ok === false) return
|
|
||||||
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
|
|
||||||
if (opts?.manual) resumeScroll()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setFollowup("failed", sessionID, id)
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editFollowup = (id: string) => {
|
const editFollowup = (id: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (followup.sending[sessionID]) return
|
if (followupBusy(sessionID)) return
|
||||||
|
|
||||||
const item = queuedFollowups().find((entry) => entry.id === id)
|
const item = queuedFollowups().find((entry) => entry.id === id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1475,6 +1479,74 @@ export default function Page() {
|
|||||||
const halt = (sessionID: string) =>
|
const halt = (sessionID: string) =>
|
||||||
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
|
||||||
|
|
||||||
|
const revertMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
const value = draft(input.messageID)
|
||||||
|
batch(() => {
|
||||||
|
roll(input.sessionID, { messageID: input.messageID })
|
||||||
|
prompt.set(value)
|
||||||
|
})
|
||||||
|
await halt(input.sessionID)
|
||||||
|
.then(() => sdk.client.session.revert(input))
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(input.sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const restoreMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const next = userMessages().find((item) => item.id > id)
|
||||||
|
const prev = prompt.current().slice()
|
||||||
|
const last = info()?.revert
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
roll(sessionID, next ? { messageID: next.id } : undefined)
|
||||||
|
if (next) {
|
||||||
|
prompt.set(draft(next.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prompt.reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = !next
|
||||||
|
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
||||||
|
: halt(sessionID).then(() =>
|
||||||
|
sdk.client.session.revert({
|
||||||
|
sessionID,
|
||||||
|
messageID: next.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await task
|
||||||
|
.then((result) => {
|
||||||
|
if (result.data) merge(result.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
batch(() => {
|
||||||
|
roll(sessionID, last)
|
||||||
|
prompt.set(prev)
|
||||||
|
})
|
||||||
|
fail(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||||
|
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||||
|
|
||||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||||
const value = draft(input.messageID)
|
const value = draft(input.messageID)
|
||||||
const dir = base64Encode(sdk.directory)
|
const dir = base64Encode(sdk.directory)
|
||||||
@@ -1496,77 +1568,13 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||||
if (ui.reverting || ui.restoring) return
|
if (reverting()) return
|
||||||
const prev = prompt.current().slice()
|
return revertMutation.mutateAsync(input)
|
||||||
const last = info()?.revert
|
|
||||||
const value = draft(input.messageID)
|
|
||||||
batch(() => {
|
|
||||||
setUi("reverting", true)
|
|
||||||
roll(input.sessionID, { messageID: input.messageID })
|
|
||||||
prompt.set(value)
|
|
||||||
})
|
|
||||||
return halt(input.sessionID)
|
|
||||||
.then(() => sdk.client.session.revert(input))
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(input.sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setUi("reverting", false)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const restore = (id: string) => {
|
const restore = (id: string) => {
|
||||||
const sessionID = params.id
|
if (!params.id || reverting()) return
|
||||||
if (!sessionID || ui.restoring || ui.reverting) return
|
return restoreMutation.mutateAsync(id)
|
||||||
|
|
||||||
const next = userMessages().find((item) => item.id > id)
|
|
||||||
const prev = prompt.current().slice()
|
|
||||||
const last = info()?.revert
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
setUi("restoring", id)
|
|
||||||
setUi("reverting", true)
|
|
||||||
roll(sessionID, next ? { messageID: next.id } : undefined)
|
|
||||||
if (next) {
|
|
||||||
prompt.set(draft(next.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prompt.reset()
|
|
||||||
})
|
|
||||||
|
|
||||||
const task = !next
|
|
||||||
? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
|
|
||||||
: halt(sessionID).then(() =>
|
|
||||||
sdk.client.session.revert({
|
|
||||||
sessionID,
|
|
||||||
messageID: next.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return task
|
|
||||||
.then((result) => {
|
|
||||||
if (result.data) merge(result.data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
batch(() => {
|
|
||||||
roll(sessionID, last)
|
|
||||||
prompt.set(prev)
|
|
||||||
})
|
|
||||||
fail(err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
batch(() => {
|
|
||||||
setUi("restoring", (value) => (value === id ? undefined : value))
|
|
||||||
setUi("reverting", false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rolled = createMemo(() => {
|
const rolled = createMemo(() => {
|
||||||
@@ -1585,7 +1593,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const item = queuedFollowups()[0]
|
const item = queuedFollowups()[0]
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (followup.sending[sessionID]) return
|
if (followupBusy(sessionID)) return
|
||||||
if (followup.failed[sessionID] === item.id) return
|
if (followup.failed[sessionID] === item.id) return
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
@@ -1780,8 +1788,8 @@ export default function Page() {
|
|||||||
rolled().length > 0
|
rolled().length > 0
|
||||||
? {
|
? {
|
||||||
items: rolled(),
|
items: rolled(),
|
||||||
restoring: ui.restoring,
|
restoring: restoring(),
|
||||||
disabled: ui.reverting,
|
disabled: reverting(),
|
||||||
onRestore: restore,
|
onRestore: restore,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
sending: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const reply = async (answers: QuestionAnswer[]) => {
|
const replyMutation = useMutation(() => ({
|
||||||
if (store.sending) return
|
mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
|
||||||
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
setStore("sending", true)
|
},
|
||||||
try {
|
onSuccess: () => {
|
||||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
|
||||||
replied = true
|
replied = true
|
||||||
cache.delete(props.request.id)
|
cache.delete(props.request.id)
|
||||||
} catch (err) {
|
},
|
||||||
fail(err)
|
onError: fail,
|
||||||
} finally {
|
}))
|
||||||
setStore("sending", false)
|
|
||||||
}
|
const rejectMutation = useMutation(() => ({
|
||||||
|
mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
|
||||||
|
onMutate: () => {
|
||||||
|
props.onSubmit()
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
replied = true
|
||||||
|
cache.delete(props.request.id)
|
||||||
|
},
|
||||||
|
onError: fail,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
|
||||||
|
|
||||||
|
const reply = async (answers: QuestionAnswer[]) => {
|
||||||
|
if (sending()) return
|
||||||
|
await replyMutation.mutateAsync(answers)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reject = async () => {
|
const reject = async () => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
|
await rejectMutation.mutateAsync()
|
||||||
props.onSubmit()
|
|
||||||
setStore("sending", true)
|
|
||||||
try {
|
|
||||||
await sdk.client.question.reject({ requestID: props.request.id })
|
|
||||||
replied = true
|
|
||||||
cache.delete(props.request.id)
|
|
||||||
} catch (err) {
|
|
||||||
fail(err)
|
|
||||||
} finally {
|
|
||||||
setStore("sending", false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
|
|
||||||
if (optIndex === options().length) {
|
if (optIndex === options().length) {
|
||||||
customOpen()
|
customOpen()
|
||||||
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
if (store.editing) commitCustom()
|
if (store.editing) commitCustom()
|
||||||
|
|
||||||
if (store.tab >= total() - 1) {
|
if (store.tab >= total() - 1) {
|
||||||
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
setStore("tab", store.tab - 1)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (store.sending) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
}
|
}
|
||||||
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||||
}
|
}
|
||||||
disabled={store.sending}
|
disabled={sending()}
|
||||||
onClick={() => jump(i())}
|
onClick={() => jump(i())}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
/>
|
/>
|
||||||
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
|
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
<Show when={store.tab > 0}>
|
<Show when={store.tab > 0}>
|
||||||
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
|
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
|
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={picked()}
|
data-picked={picked()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={picked()}
|
aria-checked={picked()}
|
||||||
disabled={store.sending}
|
disabled={sending()}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
>
|
>
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
<span data-slot="question-option-check" aria-hidden="true">
|
||||||
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={store.sending}
|
disabled={sending()}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (store.sending) {
|
if (sending()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
placeholder={language.t("ui.question.custom.placeholder")}
|
||||||
value={input()}
|
value={input()}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={store.sending}
|
disabled={sending()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -321,7 +322,6 @@ export function MessageTimeline(props: {
|
|||||||
const [title, setTitle] = createStore({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
saving: false,
|
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -335,38 +335,6 @@ export function MessageTimeline(props: {
|
|||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
|
|
||||||
const [req, setReq] = createStore({ share: false, unshare: false })
|
|
||||||
|
|
||||||
const shareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || req.share) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
setReq("share", true)
|
|
||||||
globalSDK.client.session
|
|
||||||
.share({ sessionID: id, directory: sdk.directory })
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("Failed to share session", err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setReq("share", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unshareSession = () => {
|
|
||||||
const id = sessionID()
|
|
||||||
if (!id || req.unshare) return
|
|
||||||
if (!shareEnabled()) return
|
|
||||||
setReq("unshare", true)
|
|
||||||
globalSDK.client.session
|
|
||||||
.unshare({ sessionID: id, directory: sdk.directory })
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("Failed to unshare session", err)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setReq("unshare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -382,6 +350,54 @@ export function MessageTimeline(props: {
|
|||||||
return language.t("common.requestFailed")
|
return language.t("common.requestFailed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareMutation = useMutation(() => ({
|
||||||
|
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("Failed to share session", err)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unshareMutation = useMutation(() => ({
|
||||||
|
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("Failed to unshare session", err)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const titleMutation = useMutation(() => ({
|
||||||
|
mutationFn: (input: { id: string; title: string }) =>
|
||||||
|
sdk.client.session.update({ sessionID: input.id, title: input.title }),
|
||||||
|
onSuccess: (_, input) => {
|
||||||
|
sync.set(
|
||||||
|
produce((draft) => {
|
||||||
|
const index = draft.session.findIndex((s) => s.id === input.id)
|
||||||
|
if (index !== -1) draft.session[index].title = input.title
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setTitle("editing", false)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const shareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || shareMutation.isPending) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
shareMutation.mutate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unshareSession = () => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id || unshareMutation.isPending) return
|
||||||
|
if (!shareEnabled()) return
|
||||||
|
unshareMutation.mutate(id)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -389,7 +405,6 @@ export function MessageTimeline(props: {
|
|||||||
setTitle({
|
setTitle({
|
||||||
draft: "",
|
draft: "",
|
||||||
editing: false,
|
editing: false,
|
||||||
saving: false,
|
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
pendingRename: false,
|
pendingRename: false,
|
||||||
pendingShare: false,
|
pendingShare: false,
|
||||||
@@ -408,40 +423,22 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closeTitleEditor = () => {
|
const closeTitleEditor = () => {
|
||||||
if (title.saving) return
|
if (titleMutation.isPending) return
|
||||||
setTitle({ editing: false, saving: false })
|
setTitle("editing", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTitleEditor = async () => {
|
const saveTitleEditor = () => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (title.saving) return
|
if (titleMutation.isPending) return
|
||||||
|
|
||||||
const next = title.draft.trim()
|
const next = title.draft.trim()
|
||||||
if (!next || next === (titleValue() ?? "")) {
|
if (!next || next === (titleValue() ?? "")) {
|
||||||
setTitle({ editing: false, saving: false })
|
setTitle("editing", false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle("saving", true)
|
titleMutation.mutate({ id, title: next })
|
||||||
await sdk.client.session
|
|
||||||
.update({ sessionID: id, title: next })
|
|
||||||
.then(() => {
|
|
||||||
sync.set(
|
|
||||||
produce((draft) => {
|
|
||||||
const index = draft.session.findIndex((s) => s.id === id)
|
|
||||||
if (index !== -1) draft.session[index].title = next
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setTitle({ editing: false, saving: false })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setTitle("saving", false)
|
|
||||||
showToast({
|
|
||||||
title: language.t("common.requestFailed"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
@@ -712,7 +709,7 @@ export function MessageTimeline(props: {
|
|||||||
titleRef = el
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={title.saving}
|
disabled={titleMutation.isPending}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
@@ -863,9 +860,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={shareSession}
|
onClick={shareSession}
|
||||||
disabled={req.share}
|
disabled={shareMutation.isPending}
|
||||||
>
|
>
|
||||||
{req.share
|
{shareMutation.isPending
|
||||||
? language.t("session.share.action.publishing")
|
? language.t("session.share.action.publishing")
|
||||||
: language.t("session.share.action.publish")}
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -886,9 +883,9 @@ export function MessageTimeline(props: {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
onClick={unshareSession}
|
onClick={unshareSession}
|
||||||
disabled={req.unshare}
|
disabled={unshareMutation.isPending}
|
||||||
>
|
>
|
||||||
{req.unshare
|
{unshareMutation.isPending
|
||||||
? language.t("session.share.action.unpublishing")
|
? language.t("session.share.action.unpublishing")
|
||||||
: language.t("session.share.action.unpublish")}
|
: language.t("session.share.action.unpublish")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -897,7 +894,7 @@ export function MessageTimeline(props: {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={viewShare}
|
||||||
disabled={req.unshare}
|
disabled={unshareMutation.isPending}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{language.t("session.share.action.view")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ import {
|
|||||||
FreeUsageLimitError,
|
FreeUsageLimitError,
|
||||||
SubscriptionUsageLimitError,
|
SubscriptionUsageLimitError,
|
||||||
} from "./error"
|
} from "./error"
|
||||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
|
import {
|
||||||
|
buildCostChunk,
|
||||||
|
createBodyConverter,
|
||||||
|
createStreamPartConverter,
|
||||||
|
createResponseConverter,
|
||||||
|
UsageInfo,
|
||||||
|
} from "./provider/provider"
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
import { googleHelper } from "./provider/google"
|
import { googleHelper } from "./provider/google"
|
||||||
import { openaiHelper } from "./provider/openai"
|
import { openaiHelper } from "./provider/openai"
|
||||||
@@ -90,7 +96,7 @@ export async function handler(
|
|||||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||||
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
|
||||||
logger.metric({
|
logger.metric({
|
||||||
is_tream: isStream,
|
is_stream: isStream,
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
request: requestId,
|
request: requestId,
|
||||||
client: ocClient,
|
client: ocClient,
|
||||||
@@ -230,7 +236,7 @@ export async function handler(
|
|||||||
const body = JSON.stringify(
|
const body = JSON.stringify(
|
||||||
responseConverter({
|
responseConverter({
|
||||||
...json,
|
...json,
|
||||||
cost: calculateOccuredCost(billingSource, costInfo),
|
cost: calculateOccurredCost(billingSource, costInfo),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
logger.metric({ response_length: body.length })
|
logger.metric({ response_length: body.length })
|
||||||
@@ -274,8 +280,8 @@ export async function handler(
|
|||||||
await trialLimiter?.track(usageInfo)
|
await trialLimiter?.track(usageInfo)
|
||||||
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
|
||||||
await reload(billingSource, authInfo, costInfo)
|
await reload(billingSource, authInfo, costInfo)
|
||||||
const cost = calculateOccuredCost(billingSource, costInfo)
|
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||||
c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
|
c.enqueue(encoder.encode(buildCostChunk(opts.format, cost)))
|
||||||
}
|
}
|
||||||
c.close()
|
c.close()
|
||||||
return
|
return
|
||||||
@@ -818,7 +824,7 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateOccuredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) {
|
||||||
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:")
|
||||||
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
const isBedrockModelID = providerModel.startsWith("global.anthropic.")
|
||||||
const isBedrock = isBedrockModelArn || isBedrockModelID
|
const isBedrock = isBedrockModelArn || isBedrockModelID
|
||||||
|
const isDatabricks = providerModel.startsWith("databricks-claude-")
|
||||||
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6")
|
||||||
return {
|
return {
|
||||||
format: "anthropic",
|
format: "anthropic",
|
||||||
@@ -28,7 +29,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
|
||||||
: providerApi + "/messages",
|
: providerApi + "/messages",
|
||||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||||
if (isBedrock) {
|
if (isBedrock || isDatabricks) {
|
||||||
headers.set("Authorization", `Bearer ${apiKey}`)
|
headers.set("Authorization", `Bearer ${apiKey}`)
|
||||||
} else {
|
} else {
|
||||||
headers.set("x-api-key", apiKey)
|
headers.set("x-api-key", apiKey)
|
||||||
@@ -47,9 +48,14 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
model: undefined,
|
model: undefined,
|
||||||
stream: undefined,
|
stream: undefined,
|
||||||
}
|
}
|
||||||
: {
|
: isDatabricks
|
||||||
service_tier: "standard_only",
|
? {
|
||||||
}),
|
anthropic_version: "bedrock-2023-05-31",
|
||||||
|
anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
service_tier: "standard_only",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
createBinaryStreamDecoder: () => {
|
createBinaryStreamDecoder: () => {
|
||||||
if (!isBedrock) return undefined
|
if (!isBedrock) return undefined
|
||||||
@@ -167,7 +173,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => ({
|
normalizeUsage: (usage: Usage) => ({
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
|||||||
usage = json.usageMetadata
|
usage = json.usageMetadata
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export const oaCompatHelper: ProviderHelper = () => ({
|
|||||||
usage = json.usage
|
usage = json.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
buidlCostChunk: (cost: string) => `data: ${JSON.stringify({ choices: [], cost })}\n\n`,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const openaiHelper: ProviderHelper = () => ({
|
|||||||
usage = json.response.usage
|
usage = json.response.usage
|
||||||
},
|
},
|
||||||
retrieve: () => usage,
|
retrieve: () => usage,
|
||||||
buidlCostChunk: (cost: string) => `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
normalizeUsage: (usage: Usage) => {
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export type ProviderHelper = (input: { reqModel: string; providerModel: string }
|
|||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
parse: (chunk: string) => void
|
parse: (chunk: string) => void
|
||||||
retrieve: () => any
|
retrieve: () => any
|
||||||
buidlCostChunk: (cost: string) => string
|
|
||||||
}
|
}
|
||||||
normalizeUsage: (usage: any) => UsageInfo
|
normalizeUsage: (usage: any) => UsageInfo
|
||||||
}
|
}
|
||||||
@@ -162,6 +161,19 @@ export interface CommonChunk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCostChunk(format: ZenData.Format, cost: string): string {
|
||||||
|
switch (format) {
|
||||||
|
case "anthropic":
|
||||||
|
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||||
|
case "openai":
|
||||||
|
return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||||
|
case "oa-compat":
|
||||||
|
return `data: ${JSON.stringify({ choices: [], cost })}\n\n`
|
||||||
|
default:
|
||||||
|
return `data: ${JSON.stringify({ type: "ping", cost })}\n\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) {
|
||||||
return (body: any): any => {
|
return (body: any): any => {
|
||||||
if (from === to) return body
|
if (from === to) return body
|
||||||
|
|||||||
@@ -89,8 +89,6 @@
|
|||||||
"@ai-sdk/xai": "2.0.51",
|
"@ai-sdk/xai": "2.0.51",
|
||||||
"@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",
|
||||||
"gitlab-ai-provider": "5.2.2",
|
|
||||||
"opencode-gitlab-auth": "2.0.0",
|
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@hono/standard-validator": "0.1.5",
|
"@hono/standard-validator": "0.1.5",
|
||||||
"@hono/zod-validator": "catalog:",
|
"@hono/zod-validator": "catalog:",
|
||||||
@@ -103,8 +101,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||||
"@opentui/core": "0.1.87",
|
"@opentui/core": "0.1.88",
|
||||||
"@opentui/solid": "0.1.87",
|
"@opentui/solid": "0.1.88",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -123,6 +121,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
"gitlab-ai-provider": "5.2.2",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -133,6 +132,7 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
|
"opencode-gitlab-auth": "2.0.0",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
"partial-json": "0.1.7",
|
"partial-json": "0.1.7",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
|||||||
@@ -199,6 +199,19 @@ for (const item of targets) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Smoke test: only run if binary is for current platform
|
||||||
|
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||||
|
const binaryPath = `dist/${name}/bin/opencode`
|
||||||
|
console.log(`Running smoke test: ${binaryPath} --version`)
|
||||||
|
try {
|
||||||
|
const versionOutput = await $`${binaryPath} --version`.text()
|
||||||
|
console.log(`Smoke test passed: ${versionOutput.trim()}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Smoke test failed for ${name}:`, e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await $`rm -rf ./dist/${name}/bin/tui`
|
await $`rm -rf ./dist/${name}/bin/tui`
|
||||||
await Bun.file(`dist/${name}/package.json`).write(
|
await Bun.file(`dist/${name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const seed = async () => {
|
|||||||
const { Instance } = await import("../src/project/instance")
|
const { Instance } = await import("../src/project/instance")
|
||||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||||
const { Config } = await import("../src/config/config")
|
const { Config } = await import("../src/config/config")
|
||||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
|
||||||
const { Session } = await import("../src/session")
|
const { Session } = await import("../src/session")
|
||||||
const { MessageID, PartID } = await import("../src/session/schema")
|
const { MessageID, PartID } = await import("../src/session/schema")
|
||||||
const { Project } = await import("../src/project/project")
|
const { Project } = await import("../src/project/project")
|
||||||
@@ -55,7 +54,6 @@ const seed = async () => {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await Instance.disposeAll().catch(() => {})
|
await Instance.disposeAll().catch(() => {})
|
||||||
await disposeRuntime().catch(() => {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
|||||||
|
|
||||||
## Choose scope
|
## Choose scope
|
||||||
|
|
||||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||||
|
|
||||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||||
|
|
||||||
- Shared runtime: config readers, stateless helpers, global clients
|
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||||
|
|
||||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||||
|
|
||||||
## Service shape
|
## Service shape
|
||||||
|
|
||||||
For a fully migrated module, use the public namespace directly:
|
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace Foo {
|
export namespace Foo {
|
||||||
@@ -28,53 +28,86 @@ export namespace Foo {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return Service.of({
|
// For instance-scoped services:
|
||||||
get: Effect.fn("Foo.get")(function* (id) {
|
const state = yield* InstanceState.make<State>(
|
||||||
return yield* ...
|
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||||
}),
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
// ...
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return Service.of({ get })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
// Optional: wire dependencies
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||||
|
|
||||||
|
// Per-service runtime (inside the namespace)
|
||||||
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
|
|
||||||
|
// Async facade functions
|
||||||
|
export async function get(id: FooID) {
|
||||||
|
return runPromise((svc) => svc.get(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
||||||
- Export `defaultLayer` only when wiring dependencies is useful
|
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
||||||
- Use the direct namespace form once the module is fully migrated
|
- Facade functions are plain `async function` — no `fn()` wrappers
|
||||||
|
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
||||||
|
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
||||||
|
|
||||||
## Temporary mixed-mode pattern
|
## Schema → Zod interop
|
||||||
|
|
||||||
Prefer a single namespace whenever possible.
|
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||||
|
|
||||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace FooEffect {
|
import { zod } from "@/util/effect-zod"
|
||||||
export interface Interface {
|
|
||||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||||
|
|
||||||
export const layer = Layer.effect(...)
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then keep the old boundary thin:
|
See `Auth.ZodInfo` for the canonical example.
|
||||||
|
|
||||||
|
## InstanceState init patterns
|
||||||
|
|
||||||
|
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||||
|
|
||||||
|
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export namespace Foo {
|
const cache =
|
||||||
export function get(id: FooID) {
|
yield *
|
||||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
InstanceState.make<State>(
|
||||||
}
|
Effect.fn("Foo.state")(function* (ctx) {
|
||||||
}
|
// ... load state ...
|
||||||
|
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bus.subscribeAll((event) => {
|
||||||
|
/* handle */
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(unsub) => Effect.sync(unsub),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
/* state */
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove the `Effect` suffix when the boundary split is gone.
|
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||||
|
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||||
|
|
||||||
|
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
||||||
|
|
||||||
## Scheduled Tasks
|
## Scheduled Tasks
|
||||||
|
|
||||||
@@ -107,32 +140,33 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
|||||||
|
|
||||||
## Migration checklist
|
## Migration checklist
|
||||||
|
|
||||||
Done now:
|
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||||
|
|
||||||
- [x] `AccountEffect` (mixed-mode)
|
- [x] `Account` — `account/index.ts`
|
||||||
- [x] `AuthEffect` (mixed-mode)
|
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||||
- [x] `TruncateEffect` (mixed-mode)
|
- [x] `File` — `file/index.ts`
|
||||||
- [x] `Question`
|
- [x] `FileTime` — `file/time.ts`
|
||||||
- [x] `PermissionNext`
|
- [x] `FileWatcher` — `file/watcher.ts`
|
||||||
- [x] `ProviderAuth`
|
- [x] `Format` — `format/index.ts`
|
||||||
- [x] `FileWatcher`
|
- [x] `Installation` — `installation/index.ts`
|
||||||
- [x] `FileTime`
|
- [x] `Permission` — `permission/index.ts`
|
||||||
- [x] `Format`
|
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||||
- [x] `Vcs`
|
- [x] `Question` — `question/index.ts`
|
||||||
- [x] `Skill`
|
- [x] `Skill` — `skill/index.ts`
|
||||||
- [x] `Discovery`
|
- [x] `Snapshot` — `snapshot/index.ts`
|
||||||
- [x] `File`
|
- [x] `Truncate` — `tool/truncate.ts`
|
||||||
- [x] `Snapshot`
|
- [x] `Vcs` — `project/vcs.ts`
|
||||||
|
- [x] `Discovery` — `skill/discovery.ts`
|
||||||
|
- [x] `SessionStatus`
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
Still open and likely worth migrating:
|
||||||
|
|
||||||
- [ ] `Plugin`
|
- [x] `Plugin`
|
||||||
- [ ] `ToolRegistry`
|
- [x] `ToolRegistry`
|
||||||
- [ ] `Pty`
|
- [ ] `Pty`
|
||||||
- [ ] `Worktree`
|
- [ ] `Worktree`
|
||||||
- [ ] `Installation`
|
|
||||||
- [ ] `Bus`
|
- [ ] `Bus`
|
||||||
- [ ] `Command`
|
- [x] `Command`
|
||||||
- [ ] `Config`
|
- [ ] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor`
|
||||||
|
|||||||
@@ -1,380 +0,0 @@
|
|||||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
||||||
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
|
||||||
import {
|
|
||||||
type AccountError,
|
|
||||||
AccessToken,
|
|
||||||
AccountID,
|
|
||||||
DeviceCode,
|
|
||||||
Info,
|
|
||||||
RefreshToken,
|
|
||||||
AccountServiceError,
|
|
||||||
Login,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
PollExpired,
|
|
||||||
PollPending,
|
|
||||||
type PollResult,
|
|
||||||
PollSlow,
|
|
||||||
PollSuccess,
|
|
||||||
UserCode,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export {
|
|
||||||
AccountID,
|
|
||||||
type AccountError,
|
|
||||||
AccountRepoError,
|
|
||||||
AccountServiceError,
|
|
||||||
AccessToken,
|
|
||||||
RefreshToken,
|
|
||||||
DeviceCode,
|
|
||||||
UserCode,
|
|
||||||
Info,
|
|
||||||
Org,
|
|
||||||
OrgID,
|
|
||||||
Login,
|
|
||||||
PollSuccess,
|
|
||||||
PollPending,
|
|
||||||
PollSlow,
|
|
||||||
PollExpired,
|
|
||||||
PollDenied,
|
|
||||||
PollError,
|
|
||||||
PollResult,
|
|
||||||
} from "./schema"
|
|
||||||
|
|
||||||
export type AccountOrgs = {
|
|
||||||
account: Info
|
|
||||||
orgs: readonly Org[]
|
|
||||||
}
|
|
||||||
|
|
||||||
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
|
||||||
config: Schema.Record(Schema.String, Schema.Json),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const DurationFromSeconds = Schema.Number.pipe(
|
|
||||||
Schema.decodeTo(Schema.Duration, {
|
|
||||||
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
|
||||||
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
|
||||||
device_code: DeviceCode,
|
|
||||||
user_code: UserCode,
|
|
||||||
verification_uri_complete: Schema.String,
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
interval: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
|
||||||
access_token: AccessToken,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
token_type: Schema.Literal("Bearer"),
|
|
||||||
expires_in: DurationFromSeconds,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
|
||||||
error: Schema.String,
|
|
||||||
error_description: Schema.String,
|
|
||||||
}) {
|
|
||||||
toPollResult(): PollResult {
|
|
||||||
if (this.error === "authorization_pending") return new PollPending()
|
|
||||||
if (this.error === "slow_down") return new PollSlow()
|
|
||||||
if (this.error === "expired_token") return new PollExpired()
|
|
||||||
if (this.error === "access_denied") return new PollDenied()
|
|
||||||
return new PollError({ cause: this.error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
|
||||||
|
|
||||||
class User extends Schema.Class<User>("User")({
|
|
||||||
id: AccountID,
|
|
||||||
email: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
|
||||||
|
|
||||||
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
device_code: DeviceCode,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
|
||||||
grant_type: Schema.String,
|
|
||||||
refresh_token: RefreshToken,
|
|
||||||
client_id: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const clientId = "opencode-cli"
|
|
||||||
|
|
||||||
const mapAccountServiceError =
|
|
||||||
(message = "Account service operation failed") =>
|
|
||||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
|
||||||
effect.pipe(
|
|
||||||
Effect.mapError((cause) =>
|
|
||||||
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export namespace Account {
|
|
||||||
export interface Interface {
|
|
||||||
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
|
||||||
readonly list: () => Effect.Effect<Info[], AccountError>
|
|
||||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
|
||||||
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
|
||||||
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
|
||||||
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
|
||||||
readonly config: (
|
|
||||||
accountID: AccountID,
|
|
||||||
orgID: OrgID,
|
|
||||||
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
|
||||||
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
|
||||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
|
||||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const repo = yield* AccountRepo
|
|
||||||
const http = yield* HttpClient.HttpClient
|
|
||||||
const httpRead = withTransientReadRetry(http)
|
|
||||||
const httpOk = HttpClient.filterStatusOk(http)
|
|
||||||
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
|
||||||
|
|
||||||
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
|
||||||
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
|
||||||
|
|
||||||
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => httpOk.execute(req)),
|
|
||||||
mapAccountServiceError("HTTP request failed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
|
||||||
request.pipe(
|
|
||||||
Effect.flatMap((req) => http.execute(req)),
|
|
||||||
mapAccountServiceError("HTTP request failed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
|
||||||
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
|
||||||
new TokenRefreshRequest({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: row.refresh_token,
|
|
||||||
client_id: clientId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
|
||||||
|
|
||||||
yield* repo.persistToken({
|
|
||||||
accountID: row.id,
|
|
||||||
accessToken: parsed.access_token,
|
|
||||||
refreshToken: parsed.refresh_token,
|
|
||||||
expiry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return parsed.access_token
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
|
||||||
const maybeAccount = yield* repo.getRow(accountID)
|
|
||||||
if (Option.isNone(maybeAccount)) return Option.none()
|
|
||||||
|
|
||||||
const account = maybeAccount.value
|
|
||||||
const accessToken = yield* resolveToken(account)
|
|
||||||
return Option.some({ account, accessToken })
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
|
||||||
const response = yield* executeReadOk(
|
|
||||||
HttpClientRequest.get(`${url}/api/user`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
|
||||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
|
||||||
const accounts = yield* repo.list()
|
|
||||||
const [errors, results] = yield* Effect.partition(
|
|
||||||
accounts,
|
|
||||||
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
|
||||||
{ concurrency: 3 },
|
|
||||||
)
|
|
||||||
for (const error of errors) {
|
|
||||||
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
|
||||||
Effect.annotateLogs({ error: String(error) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
})
|
|
||||||
|
|
||||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return []
|
|
||||||
|
|
||||||
const { account, accessToken } = resolved.value
|
|
||||||
|
|
||||||
return yield* fetchOrgs(account.url, accessToken)
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
|
||||||
const resolved = yield* resolveAccess(accountID)
|
|
||||||
if (Option.isNone(resolved)) return Option.none()
|
|
||||||
|
|
||||||
const { account, accessToken } = resolved.value
|
|
||||||
|
|
||||||
const response = yield* executeRead(
|
|
||||||
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.bearerToken(accessToken),
|
|
||||||
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.status === 404) return Option.none()
|
|
||||||
|
|
||||||
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
return Option.some(parsed.config)
|
|
||||||
})
|
|
||||||
|
|
||||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
|
||||||
const response = yield* executeEffectOk(
|
|
||||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
return new Login({
|
|
||||||
code: parsed.device_code,
|
|
||||||
user: parsed.user_code,
|
|
||||||
url: `${server}${parsed.verification_uri_complete}`,
|
|
||||||
server,
|
|
||||||
expiry: parsed.expires_in,
|
|
||||||
interval: parsed.interval,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
|
||||||
const response = yield* executeEffect(
|
|
||||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
|
||||||
HttpClientRequest.acceptJson,
|
|
||||||
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
|
||||||
new DeviceTokenRequest({
|
|
||||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
||||||
device_code: input.code,
|
|
||||||
client_id: clientId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
|
||||||
mapAccountServiceError("Failed to decode response"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
|
||||||
const accessToken = parsed.access_token
|
|
||||||
|
|
||||||
const user = fetchUser(input.server, accessToken)
|
|
||||||
const orgs = fetchOrgs(input.server, accessToken)
|
|
||||||
|
|
||||||
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
|
||||||
|
|
||||||
// TODO: When there are multiple orgs, let the user choose
|
|
||||||
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
|
||||||
|
|
||||||
const now = yield* Clock.currentTimeMillis
|
|
||||||
const expiry = now + Duration.toMillis(parsed.expires_in)
|
|
||||||
const refreshToken = parsed.refresh_token
|
|
||||||
|
|
||||||
yield* repo.persistAccount({
|
|
||||||
id: account.id,
|
|
||||||
email: account.email,
|
|
||||||
url: input.server,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
expiry,
|
|
||||||
orgID: firstOrgID,
|
|
||||||
})
|
|
||||||
|
|
||||||
return new PollSuccess({ email: account.email })
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
active: repo.active,
|
|
||||||
list: repo.list,
|
|
||||||
orgsByAccount,
|
|
||||||
remove: repo.remove,
|
|
||||||
use: repo.use,
|
|
||||||
orgs,
|
|
||||||
config,
|
|
||||||
token,
|
|
||||||
login,
|
|
||||||
poll,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,397 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
|
import {
|
||||||
|
type AccountError,
|
||||||
|
AccessToken,
|
||||||
|
AccountID,
|
||||||
|
DeviceCode,
|
||||||
|
Info,
|
||||||
|
RefreshToken,
|
||||||
|
AccountServiceError,
|
||||||
|
Login,
|
||||||
|
Org,
|
||||||
|
OrgID,
|
||||||
|
PollDenied,
|
||||||
|
PollError,
|
||||||
|
PollExpired,
|
||||||
|
PollPending,
|
||||||
|
type PollResult,
|
||||||
|
PollSlow,
|
||||||
|
PollSuccess,
|
||||||
|
UserCode,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
export {
|
||||||
|
AccountID,
|
||||||
|
type AccountError,
|
||||||
|
AccountRepoError,
|
||||||
|
AccountServiceError,
|
||||||
|
AccessToken,
|
||||||
|
RefreshToken,
|
||||||
|
DeviceCode,
|
||||||
|
UserCode,
|
||||||
|
Info,
|
||||||
|
Org,
|
||||||
|
OrgID,
|
||||||
|
Login,
|
||||||
|
PollSuccess,
|
||||||
|
PollPending,
|
||||||
|
PollSlow,
|
||||||
|
PollExpired,
|
||||||
|
PollDenied,
|
||||||
|
PollError,
|
||||||
|
PollResult,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
import { runtime } from "@/effect/runtime"
|
export type AccountOrgs = {
|
||||||
|
account: Info
|
||||||
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
orgs: readonly Org[]
|
||||||
return runtime.runSync(S.Service.use(f))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
|
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
|
||||||
return runtime.runPromise(S.Service.use(f))
|
config: Schema.Record(Schema.String, Schema.Json),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const DurationFromSeconds = Schema.Number.pipe(
|
||||||
|
Schema.decodeTo(Schema.Duration, {
|
||||||
|
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
|
||||||
|
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
|
||||||
|
device_code: DeviceCode,
|
||||||
|
user_code: UserCode,
|
||||||
|
verification_uri_complete: Schema.String,
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
interval: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
|
||||||
|
access_token: AccessToken,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
token_type: Schema.Literal("Bearer"),
|
||||||
|
expires_in: DurationFromSeconds,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
|
||||||
|
error: Schema.String,
|
||||||
|
error_description: Schema.String,
|
||||||
|
}) {
|
||||||
|
toPollResult(): PollResult {
|
||||||
|
if (this.error === "authorization_pending") return new PollPending()
|
||||||
|
if (this.error === "slow_down") return new PollSlow()
|
||||||
|
if (this.error === "expired_token") return new PollExpired()
|
||||||
|
if (this.error === "access_denied") return new PollDenied()
|
||||||
|
return new PollError({ cause: this.error })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
|
||||||
|
|
||||||
|
class User extends Schema.Class<User>("User")({
|
||||||
|
id: AccountID,
|
||||||
|
email: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
|
||||||
|
|
||||||
|
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
device_code: DeviceCode,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
|
||||||
|
grant_type: Schema.String,
|
||||||
|
refresh_token: RefreshToken,
|
||||||
|
client_id: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const clientId = "opencode-cli"
|
||||||
|
|
||||||
|
const mapAccountServiceError =
|
||||||
|
(message = "Account service operation failed") =>
|
||||||
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
|
||||||
|
effect.pipe(
|
||||||
|
Effect.mapError((cause) =>
|
||||||
|
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
export namespace Account {
|
export namespace Account {
|
||||||
export const Info = Model
|
export interface Interface {
|
||||||
export type Info = Model
|
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
|
||||||
|
readonly list: () => Effect.Effect<Info[], AccountError>
|
||||||
|
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||||
|
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
|
||||||
|
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
|
||||||
|
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
|
||||||
|
readonly config: (
|
||||||
|
accountID: AccountID,
|
||||||
|
orgID: OrgID,
|
||||||
|
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
|
||||||
|
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
|
||||||
|
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||||
|
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||||
|
}
|
||||||
|
|
||||||
export function active(): Info | undefined {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||||
return Option.getOrUndefined(runSync((service) => service.active()))
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const repo = yield* AccountRepo
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
const httpRead = withTransientReadRetry(http)
|
||||||
|
const httpOk = HttpClient.filterStatusOk(http)
|
||||||
|
const httpReadOk = HttpClient.filterStatusOk(httpRead)
|
||||||
|
|
||||||
|
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
|
||||||
|
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
|
||||||
|
|
||||||
|
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => httpOk.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
|
||||||
|
request.pipe(
|
||||||
|
Effect.flatMap((req) => http.execute(req)),
|
||||||
|
mapAccountServiceError("HTTP request failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||||
|
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
|
||||||
|
new TokenRefreshRequest({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: row.refresh_token,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
|
||||||
|
|
||||||
|
yield* repo.persistToken({
|
||||||
|
accountID: row.id,
|
||||||
|
accessToken: parsed.access_token,
|
||||||
|
refreshToken: parsed.refresh_token,
|
||||||
|
expiry,
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed.access_token
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||||
|
const maybeAccount = yield* repo.getRow(accountID)
|
||||||
|
if (Option.isNone(maybeAccount)) return Option.none()
|
||||||
|
|
||||||
|
const account = maybeAccount.value
|
||||||
|
const accessToken = yield* resolveToken(account)
|
||||||
|
return Option.some({ account, accessToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/orgs`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
|
||||||
|
const response = yield* executeReadOk(
|
||||||
|
HttpClientRequest.get(`${url}/api/user`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||||
|
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||||
|
const accounts = yield* repo.list()
|
||||||
|
const [errors, results] = yield* Effect.partition(
|
||||||
|
accounts,
|
||||||
|
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
|
||||||
|
{ concurrency: 3 },
|
||||||
|
)
|
||||||
|
for (const error of errors) {
|
||||||
|
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
|
||||||
|
Effect.annotateLogs({ error: String(error) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return []
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
return yield* fetchOrgs(account.url, accessToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||||
|
const resolved = yield* resolveAccess(accountID)
|
||||||
|
if (Option.isNone(resolved)) return Option.none()
|
||||||
|
|
||||||
|
const { account, accessToken } = resolved.value
|
||||||
|
|
||||||
|
const response = yield* executeRead(
|
||||||
|
HttpClientRequest.get(`${account.url}/api/config`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.bearerToken(accessToken),
|
||||||
|
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) return Option.none()
|
||||||
|
|
||||||
|
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return Option.some(parsed.config)
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||||
|
const response = yield* executeEffectOk(
|
||||||
|
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
return new Login({
|
||||||
|
code: parsed.device_code,
|
||||||
|
user: parsed.user_code,
|
||||||
|
url: `${server}${parsed.verification_uri_complete}`,
|
||||||
|
server,
|
||||||
|
expiry: parsed.expires_in,
|
||||||
|
interval: parsed.interval,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||||
|
const response = yield* executeEffect(
|
||||||
|
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||||
|
HttpClientRequest.acceptJson,
|
||||||
|
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
|
||||||
|
new DeviceTokenRequest({
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
device_code: input.code,
|
||||||
|
client_id: clientId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
|
||||||
|
mapAccountServiceError("Failed to decode response"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
|
||||||
|
const accessToken = parsed.access_token
|
||||||
|
|
||||||
|
const user = fetchUser(input.server, accessToken)
|
||||||
|
const orgs = fetchOrgs(input.server, accessToken)
|
||||||
|
|
||||||
|
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
|
||||||
|
|
||||||
|
// TODO: When there are multiple orgs, let the user choose
|
||||||
|
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
|
||||||
|
|
||||||
|
const now = yield* Clock.currentTimeMillis
|
||||||
|
const expiry = now + Duration.toMillis(parsed.expires_in)
|
||||||
|
const refreshToken = parsed.refresh_token
|
||||||
|
|
||||||
|
yield* repo.persistAccount({
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
url: input.server,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiry,
|
||||||
|
orgID: firstOrgID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new PollSuccess({ email: account.email })
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
active: repo.active,
|
||||||
|
list: repo.list,
|
||||||
|
orgsByAccount,
|
||||||
|
remove: repo.remove,
|
||||||
|
use: repo.use,
|
||||||
|
orgs,
|
||||||
|
config,
|
||||||
|
token,
|
||||||
|
login,
|
||||||
|
poll,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
|
||||||
|
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function active(): Promise<Info | undefined> {
|
||||||
|
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
|
||||||
const config = await runPromise((service) => service.config(accountID, orgID))
|
const cfg = await runPromise((service) => service.config(accountID, orgID))
|
||||||
return Option.getOrUndefined(config)
|
return Option.getOrUndefined(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||||
const token = await runPromise((service) => service.token(accountID))
|
const t = await runPromise((service) => service.token(accountID))
|
||||||
return Option.getOrUndefined(token)
|
return Option.getOrUndefined(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
|||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -32,7 +32,7 @@ export namespace Agent {
|
|||||||
topP: z.number().optional(),
|
topP: z.number().optional(),
|
||||||
temperature: z.number().optional(),
|
temperature: z.number().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
permission: PermissionNext.Ruleset,
|
permission: Permission.Ruleset,
|
||||||
model: z
|
model: z
|
||||||
.object({
|
.object({
|
||||||
modelID: ModelID.zod,
|
modelID: ModelID.zod,
|
||||||
@@ -54,7 +54,7 @@ export namespace Agent {
|
|||||||
|
|
||||||
const skillDirs = await Skill.dirs()
|
const skillDirs = await Skill.dirs()
|
||||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||||
const defaults = PermissionNext.fromConfig({
|
const defaults = Permission.fromConfig({
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
doom_loop: "ask",
|
doom_loop: "ask",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -72,16 +72,16 @@ export namespace Agent {
|
|||||||
"*.env.example": "allow",
|
"*.env.example": "allow",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const user = PermissionNext.fromConfig(cfg.permission ?? {})
|
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
const result: Record<string, Info> = {
|
||||||
build: {
|
build: {
|
||||||
name: "build",
|
name: "build",
|
||||||
description: "The default agent. Executes tools based on configured permissions.",
|
description: "The default agent. Executes tools based on configured permissions.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_enter: "allow",
|
plan_enter: "allow",
|
||||||
}),
|
}),
|
||||||
@@ -94,9 +94,9 @@ export namespace Agent {
|
|||||||
name: "plan",
|
name: "plan",
|
||||||
description: "Plan mode. Disallows all edit tools.",
|
description: "Plan mode. Disallows all edit tools.",
|
||||||
options: {},
|
options: {},
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
question: "allow",
|
question: "allow",
|
||||||
plan_exit: "allow",
|
plan_exit: "allow",
|
||||||
external_directory: {
|
external_directory: {
|
||||||
@@ -116,9 +116,9 @@ export namespace Agent {
|
|||||||
general: {
|
general: {
|
||||||
name: "general",
|
name: "general",
|
||||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
todoread: "deny",
|
todoread: "deny",
|
||||||
todowrite: "deny",
|
todowrite: "deny",
|
||||||
}),
|
}),
|
||||||
@@ -130,9 +130,9 @@ export namespace Agent {
|
|||||||
},
|
},
|
||||||
explore: {
|
explore: {
|
||||||
name: "explore",
|
name: "explore",
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
grep: "allow",
|
grep: "allow",
|
||||||
glob: "allow",
|
glob: "allow",
|
||||||
@@ -161,9 +161,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
prompt: PROMPT_COMPACTION,
|
prompt: PROMPT_COMPACTION,
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -177,9 +177,9 @@ export namespace Agent {
|
|||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -192,9 +192,9 @@ export namespace Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
native: true,
|
native: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
permission: PermissionNext.merge(
|
permission: Permission.merge(
|
||||||
defaults,
|
defaults,
|
||||||
PermissionNext.fromConfig({
|
Permission.fromConfig({
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
}),
|
}),
|
||||||
user,
|
user,
|
||||||
@@ -213,7 +213,7 @@ export namespace Agent {
|
|||||||
item = result[key] = {
|
item = result[key] = {
|
||||||
name: key,
|
name: key,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
permission: PermissionNext.merge(defaults, user),
|
permission: Permission.merge(defaults, user),
|
||||||
options: {},
|
options: {},
|
||||||
native: false,
|
native: false,
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ export namespace Agent {
|
|||||||
item.name = value.name ?? item.name
|
item.name = value.name ?? item.name
|
||||||
item.steps = value.steps ?? item.steps
|
item.steps = value.steps ?? item.steps
|
||||||
item.options = mergeDeep(item.options, value.options ?? {})
|
item.options = mergeDeep(item.options, value.options ?? {})
|
||||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||||
@@ -242,9 +242,9 @@ export namespace Agent {
|
|||||||
})
|
})
|
||||||
if (explicit) continue
|
if (explicit) continue
|
||||||
|
|
||||||
result[name].permission = PermissionNext.merge(
|
result[name].permission = Permission.merge(
|
||||||
result[name].permission,
|
result[name].permission,
|
||||||
PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
|
||||||
|
|
||||||
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
|
||||||
type: Schema.Literal("oauth"),
|
|
||||||
refresh: Schema.String,
|
|
||||||
access: Schema.String,
|
|
||||||
expires: Schema.Number,
|
|
||||||
accountId: Schema.optional(Schema.String),
|
|
||||||
enterpriseUrl: Schema.optional(Schema.String),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
|
||||||
type: Schema.Literal("api"),
|
|
||||||
key: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
|
||||||
type: Schema.Literal("wellknown"),
|
|
||||||
key: Schema.String,
|
|
||||||
token: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
|
||||||
|
|
||||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const file = path.join(Global.Path.data, "auth.json")
|
|
||||||
|
|
||||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
|
||||||
|
|
||||||
export namespace Auth {
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
|
||||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
|
||||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
|
||||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(() =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: async () => {
|
|
||||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
|
||||||
},
|
|
||||||
catch: fail("Failed to read auth data"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
|
||||||
return (yield* all())[providerID]
|
|
||||||
})
|
|
||||||
|
|
||||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
if (norm !== key) delete data[key]
|
|
||||||
delete data[norm + "/"]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
|
||||||
const norm = key.replace(/\/+$/, "")
|
|
||||||
const data = yield* all()
|
|
||||||
delete data[key]
|
|
||||||
delete data[norm]
|
|
||||||
yield* Effect.tryPromise({
|
|
||||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,101 @@
|
|||||||
import { Effect } from "effect"
|
import path from "path"
|
||||||
import z from "zod"
|
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import * as S from "./effect"
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
|
||||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
|
const file = path.join(Global.Path.data, "auth.json")
|
||||||
return runtime.runPromise(S.Auth.Service.use(f))
|
|
||||||
}
|
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||||
.object({
|
type: Schema.Literal("oauth"),
|
||||||
type: z.literal("oauth"),
|
refresh: Schema.String,
|
||||||
refresh: z.string(),
|
access: Schema.String,
|
||||||
access: z.string(),
|
expires: Schema.Number,
|
||||||
expires: z.number(),
|
accountId: Schema.optional(Schema.String),
|
||||||
accountId: z.string().optional(),
|
enterpriseUrl: Schema.optional(Schema.String),
|
||||||
enterpriseUrl: z.string().optional(),
|
}) {}
|
||||||
})
|
|
||||||
.meta({ ref: "OAuth" })
|
|
||||||
|
|
||||||
export const Api = z
|
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||||
.object({
|
type: Schema.Literal("api"),
|
||||||
type: z.literal("api"),
|
key: Schema.String,
|
||||||
key: z.string(),
|
}) {}
|
||||||
})
|
|
||||||
.meta({ ref: "ApiAuth" })
|
|
||||||
|
|
||||||
export const WellKnown = z
|
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||||
.object({
|
type: Schema.Literal("wellknown"),
|
||||||
type: z.literal("wellknown"),
|
key: Schema.String,
|
||||||
key: z.string(),
|
token: Schema.String,
|
||||||
token: z.string(),
|
}) {}
|
||||||
})
|
|
||||||
.meta({ ref: "WellKnownAuth" })
|
|
||||||
|
|
||||||
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
|
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
|
||||||
export type Info = z.infer<typeof Info>
|
export const Info = Object.assign(_Info, { zod: zod(_Info) })
|
||||||
|
export type Info = Schema.Schema.Type<typeof _Info>
|
||||||
|
|
||||||
|
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||||
|
message: Schema.String,
|
||||||
|
cause: Schema.optional(Schema.Defect),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||||
|
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||||
|
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||||
|
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
|
const all = Effect.fn("Auth.all")(() =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||||
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
|
},
|
||||||
|
catch: fail("Failed to read auth data"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||||
|
return (yield* all())[providerID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
if (norm !== key) delete data[key]
|
||||||
|
delete data[norm + "/"]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||||
|
const norm = key.replace(/\/+$/, "")
|
||||||
|
const data = yield* all()
|
||||||
|
delete data[key]
|
||||||
|
delete data[norm]
|
||||||
|
yield* Effect.tryPromise({
|
||||||
|
try: () => Filesystem.writeJson(file, data, 0o600),
|
||||||
|
catch: fail("Failed to write auth data"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, set, remove })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
export async function get(providerID: string) {
|
||||||
return runPromise((service) => service.get(providerID))
|
return runPromise((service) => service.get(providerID))
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Duration, Effect, Match, Option } from "effect"
|
import { Duration, Effect, Match, Option } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await runtime.runPromise(loginEffect(args.url))
|
await Account.runPromise((_svc) => loginEffect(args.url))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await runtime.runPromise(logoutEffect(args.email))
|
await Account.runPromise((_svc) => logoutEffect(args.email))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await runtime.runPromise(switchEffect())
|
await Account.runPromise((_svc) => switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await runtime.runPromise(orgsEffect())
|
await Account.runPromise((_svc) => orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
|||||||
import { MessageID, PartID } from "../../../session/schema"
|
import { MessageID, PartID } from "../../../session/schema"
|
||||||
import { ToolRegistry } from "../../../tool/registry"
|
import { ToolRegistry } from "../../../tool/registry"
|
||||||
import { Instance } from "../../../project/instance"
|
import { Instance } from "../../../project/instance"
|
||||||
import { PermissionNext } from "../../../permission"
|
import { Permission } from "../../../permission"
|
||||||
import { iife } from "../../../util/iife"
|
import { iife } from "../../../util/iife"
|
||||||
import { bootstrap } from "../../bootstrap"
|
import { bootstrap } from "../../bootstrap"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||||
const disabled = PermissionNext.disabled(
|
const disabled = Permission.disabled(
|
||||||
availableTools.map((tool) => tool.id),
|
availableTools.map((tool) => tool.id),
|
||||||
agent.permission,
|
agent.permission,
|
||||||
)
|
)
|
||||||
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
}
|
}
|
||||||
await Session.updateMessage(message)
|
await Session.updateMessage(message)
|
||||||
|
|
||||||
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
|
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
|
|||||||
abort: new AbortController().signal,
|
abort: new AbortController().signal,
|
||||||
messages: [],
|
messages: [],
|
||||||
metadata: () => {},
|
metadata: () => {},
|
||||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||||
for (const pattern of req.patterns) {
|
for (const pattern of req.patterns) {
|
||||||
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
|
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||||
if (rule.action === "deny") {
|
if (rule.action === "deny") {
|
||||||
throw new PermissionNext.DeniedError({ ruleset })
|
throw new Permission.DeniedError({ ruleset })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
|||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { Provider } from "../../provider/provider"
|
import { Provider } from "../../provider/provider"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { PermissionNext } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
import { Tool } from "../../tool/tool"
|
import { Tool } from "../../tool/tool"
|
||||||
import { GlobTool } from "../../tool/glob"
|
import { GlobTool } from "../../tool/glob"
|
||||||
import { GrepTool } from "../../tool/grep"
|
import { GrepTool } from "../../tool/grep"
|
||||||
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules: PermissionNext.Ruleset = [
|
const rules: Permission.Ruleset = [
|
||||||
{
|
{
|
||||||
permission: "question",
|
permission: "question",
|
||||||
action: "deny",
|
action: "deny",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core"
|
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
|
||||||
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
|
||||||
import "opentui-spinner/solid"
|
import "opentui-spinner/solid"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -934,7 +934,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
// Normalize line endings at the boundary
|
// Normalize line endings at the boundary
|
||||||
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
|
||||||
// Replace CRLF first, then any remaining CR
|
// Replace CRLF first, then any remaining CR
|
||||||
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
const normalizedText = decodePasteBytes(event.bytes).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||||
const pastedContent = normalizedText.trim()
|
const pastedContent = normalizedText.trim()
|
||||||
if (!pastedContent) {
|
if (!pastedContent) {
|
||||||
command.trigger("prompt.paste")
|
command.trigger("prompt.paste")
|
||||||
|
|||||||
@@ -1667,6 +1667,7 @@ function InlineTool(props: {
|
|||||||
|
|
||||||
const denied = createMemo(
|
const denied = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
error()?.includes("QuestionRejectedError") ||
|
||||||
error()?.includes("rejected permission") ||
|
error()?.includes("rejected permission") ||
|
||||||
error()?.includes("specified a rule") ||
|
error()?.includes("specified a rule") ||
|
||||||
error()?.includes("user dismissed"),
|
error()?.includes("user dismissed"),
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
|
||||||
import PROMPT_REVIEW from "./template/review.txt"
|
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||||
|
import PROMPT_REVIEW from "./template/review.txt"
|
||||||
|
|
||||||
export namespace Command {
|
export namespace Command {
|
||||||
|
const log = Log.create({ service: "command" })
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
commands: Record<string, Info>
|
||||||
|
}
|
||||||
|
|
||||||
export const Event = {
|
export const Event = {
|
||||||
Executed: BusEvent.define(
|
Executed: BusEvent.define(
|
||||||
"command.executed",
|
"command.executed",
|
||||||
@@ -42,7 +50,7 @@ export namespace Command {
|
|||||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||||
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||||
|
|
||||||
export function hints(template: string): string[] {
|
export function hints(template: string) {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
const numbered = template.match(/\$\d+/g)
|
const numbered = template.match(/\$\d+/g)
|
||||||
if (numbered) {
|
if (numbered) {
|
||||||
@@ -57,95 +65,121 @@ export namespace Command {
|
|||||||
REVIEW: "review",
|
REVIEW: "review",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
export interface Interface {
|
||||||
const cfg = await Config.get()
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
const result: Record<string, Info> = {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
||||||
[Default.INIT]: {
|
|
||||||
name: Default.INIT,
|
|
||||||
description: "create/update AGENTS.md",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
|
||||||
},
|
|
||||||
hints: hints(PROMPT_INITIALIZE),
|
|
||||||
},
|
|
||||||
[Default.REVIEW]: {
|
|
||||||
name: Default.REVIEW,
|
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
|
||||||
},
|
|
||||||
subtask: true,
|
|
||||||
hints: hints(PROMPT_REVIEW),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
export const layer = Layer.effect(
|
||||||
result[name] = {
|
Service,
|
||||||
name,
|
Effect.gen(function* () {
|
||||||
agent: command.agent,
|
const init = Effect.fn("Command.state")(function* (ctx) {
|
||||||
model: command.model,
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
description: command.description,
|
const commands: Record<string, Info> = {}
|
||||||
source: "command",
|
|
||||||
get template() {
|
|
||||||
return command.template
|
|
||||||
},
|
|
||||||
subtask: command.subtask,
|
|
||||||
hints: hints(command.template),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
|
||||||
result[name] = {
|
|
||||||
name,
|
|
||||||
source: "mcp",
|
|
||||||
description: prompt.description,
|
|
||||||
get template() {
|
|
||||||
// since a getter can't be async we need to manually return a promise here
|
|
||||||
return new Promise<string>(async (resolve, reject) => {
|
|
||||||
const template = await MCP.getPrompt(
|
|
||||||
prompt.client,
|
|
||||||
prompt.name,
|
|
||||||
prompt.arguments
|
|
||||||
? // substitute each argument with $1, $2, etc.
|
|
||||||
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
|
||||||
: {},
|
|
||||||
).catch(reject)
|
|
||||||
resolve(
|
|
||||||
template?.messages
|
|
||||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
|
||||||
.join("\n") || "",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add skills as invokable commands
|
commands[Default.INIT] = {
|
||||||
for (const skill of await Skill.all()) {
|
name: Default.INIT,
|
||||||
// Skip if a command with this name already exists
|
description: "create/update AGENTS.md",
|
||||||
if (result[skill.name]) continue
|
source: "command",
|
||||||
result[skill.name] = {
|
get template() {
|
||||||
name: skill.name,
|
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||||
description: skill.description,
|
},
|
||||||
source: "skill",
|
hints: hints(PROMPT_INITIALIZE),
|
||||||
get template() {
|
}
|
||||||
return skill.content
|
commands[Default.REVIEW] = {
|
||||||
},
|
name: Default.REVIEW,
|
||||||
hints: [],
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
}
|
source: "command",
|
||||||
}
|
get template() {
|
||||||
|
return PROMPT_REVIEW.replace("${path}", ctx.worktree)
|
||||||
|
},
|
||||||
|
subtask: true,
|
||||||
|
hints: hints(PROMPT_REVIEW),
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||||
})
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
agent: command.agent,
|
||||||
|
model: command.model,
|
||||||
|
description: command.description,
|
||||||
|
source: "command",
|
||||||
|
get template() {
|
||||||
|
return command.template
|
||||||
|
},
|
||||||
|
subtask: command.subtask,
|
||||||
|
hints: hints(command.template),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
|
||||||
|
commands[name] = {
|
||||||
|
name,
|
||||||
|
source: "mcp",
|
||||||
|
description: prompt.description,
|
||||||
|
get template() {
|
||||||
|
return new Promise<string>(async (resolve, reject) => {
|
||||||
|
const template = await MCP.getPrompt(
|
||||||
|
prompt.client,
|
||||||
|
prompt.name,
|
||||||
|
prompt.arguments
|
||||||
|
? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||||
|
: {},
|
||||||
|
).catch(reject)
|
||||||
|
resolve(
|
||||||
|
template?.messages
|
||||||
|
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||||
|
.join("\n") || "",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skill of yield* Effect.promise(() => Skill.all())) {
|
||||||
|
if (commands[skill.name]) continue
|
||||||
|
commands[skill.name] = {
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
source: "skill",
|
||||||
|
get template() {
|
||||||
|
return skill.content
|
||||||
|
},
|
||||||
|
hints: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||||
|
|
||||||
|
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return state.commands[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Command.list")(function* () {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return Object.values(state.commands)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export async function get(name: string) {
|
export async function get(name: string) {
|
||||||
return state().then((x) => x[name])
|
return runPromise((svc) => svc.get(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return state().then((x) => Object.values(x))
|
return runPromise((svc) => svc.list())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export namespace Config {
|
|||||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = Account.active()
|
const active = await Account.active()
|
||||||
if (active?.active_org_id) {
|
if (active?.active_org_id) {
|
||||||
try {
|
try {
|
||||||
const [config, token] = await Promise.all([
|
const [config, token] = await Promise.all([
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Effect, ScopedCache, Scope } from "effect"
|
||||||
|
import { Instance, type Shape } from "@/project/instance"
|
||||||
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
||||||
|
export interface InstanceState<A, E = never, R = never> {
|
||||||
|
readonly [TypeId]: typeof TypeId
|
||||||
|
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace InstanceState {
|
||||||
|
export const make = <A, E = never, R = never>(
|
||||||
|
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||||
|
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||||
|
capacity: Number.POSITIVE_INFINITY,
|
||||||
|
lookup: () => init(Instance.current),
|
||||||
|
})
|
||||||
|
|
||||||
|
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||||
|
|
||||||
|
return {
|
||||||
|
[TypeId]: TypeId,
|
||||||
|
cache,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||||
|
|
||||||
|
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||||
|
Effect.map(get(self), select)
|
||||||
|
|
||||||
|
export const useEffect = <A, E, R, B, E2, R2>(
|
||||||
|
self: InstanceState<A, E, R>,
|
||||||
|
select: (value: A) => Effect.Effect<B, E2, R2>,
|
||||||
|
) => Effect.flatMap(get(self), select)
|
||||||
|
|
||||||
|
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||||
|
|
||||||
|
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
|
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
|
||||||
import { File } from "@/file/service"
|
|
||||||
import { FileTime } from "@/file/time-service"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
|
||||||
import { Format } from "@/format/service"
|
|
||||||
import { Permission } from "@/permission/service"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import { Vcs } from "@/project/vcs"
|
|
||||||
import { ProviderAuth } from "@/provider/auth-service"
|
|
||||||
import { Question } from "@/question/service"
|
|
||||||
import { Skill } from "@/skill/service"
|
|
||||||
import { Snapshot } from "@/snapshot/service"
|
|
||||||
import { InstanceContext } from "./instance-context"
|
|
||||||
import { registerDisposer } from "./instance-registry"
|
|
||||||
|
|
||||||
export { InstanceContext } from "./instance-context"
|
|
||||||
|
|
||||||
export type InstanceServices =
|
|
||||||
| Question.Service
|
|
||||||
| Permission.Service
|
|
||||||
| ProviderAuth.Service
|
|
||||||
| FileWatcher.Service
|
|
||||||
| Vcs.Service
|
|
||||||
| FileTime.Service
|
|
||||||
| Format.Service
|
|
||||||
| File.Service
|
|
||||||
| Skill.Service
|
|
||||||
| Snapshot.Service
|
|
||||||
|
|
||||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
|
||||||
// the full instance context (directory, worktree, project). We read from the
|
|
||||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
|
||||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
|
||||||
// This should go away once the old Instance type is removed and lookup can load
|
|
||||||
// the full context directly.
|
|
||||||
function lookup(_key: string) {
|
|
||||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
|
||||||
return Layer.mergeAll(
|
|
||||||
Question.layer,
|
|
||||||
Permission.layer,
|
|
||||||
ProviderAuth.defaultLayer,
|
|
||||||
FileWatcher.layer,
|
|
||||||
Vcs.layer,
|
|
||||||
FileTime.layer,
|
|
||||||
Format.layer,
|
|
||||||
File.layer,
|
|
||||||
Skill.defaultLayer,
|
|
||||||
Snapshot.defaultLayer,
|
|
||||||
).pipe(Layer.provide(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
|
|
||||||
"opencode/Instances",
|
|
||||||
) {
|
|
||||||
static readonly layer = Layer.effect(
|
|
||||||
Instances,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
|
|
||||||
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(unregister))
|
|
||||||
return Instances.of(layerMap)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
|
||||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import * as ServiceMap from "effect/ServiceMap"
|
||||||
|
|
||||||
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
|
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
|
|
||||||
|
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
||||||
|
rt ??= ManagedRuntime.make(layer, { memoMap })
|
||||||
|
return rt.runPromise(service.use(fn), options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
|
||||||
import { Account } from "@/account/effect"
|
|
||||||
import { Auth } from "@/auth/effect"
|
|
||||||
import { Instances } from "@/effect/instances"
|
|
||||||
import type { InstanceServices } from "@/effect/instances"
|
|
||||||
import { Installation } from "@/installation"
|
|
||||||
import { Truncate } from "@/tool/truncate-effect"
|
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
|
|
||||||
export const runtime = ManagedRuntime.make(
|
|
||||||
Layer.mergeAll(
|
|
||||||
Account.defaultLayer, //
|
|
||||||
Installation.defaultLayer,
|
|
||||||
Truncate.defaultLayer,
|
|
||||||
Instances.layer,
|
|
||||||
).pipe(Layer.provideMerge(Auth.layer)),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
|
||||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disposeRuntime() {
|
|
||||||
return runtime.dispose()
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,712 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { File as S } from "./service"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { git } from "@/util/git"
|
||||||
|
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||||
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
import fs from "fs"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
|
import ignore from "ignore"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
||||||
export namespace File {
|
export namespace File {
|
||||||
export const Info = S.Info
|
export const Info = z
|
||||||
export type Info = S.Info
|
.object({
|
||||||
|
path: z.string(),
|
||||||
|
added: z.number().int(),
|
||||||
|
removed: z.number().int(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "File",
|
||||||
|
})
|
||||||
|
|
||||||
export const Node = S.Node
|
export type Info = z.infer<typeof Info>
|
||||||
export type Node = S.Node
|
|
||||||
|
|
||||||
export const Content = S.Content
|
export const Node = z
|
||||||
export type Content = S.Content
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
absolute: z.string(),
|
||||||
|
type: z.enum(["file", "directory"]),
|
||||||
|
ignored: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileNode",
|
||||||
|
})
|
||||||
|
export type Node = z.infer<typeof Node>
|
||||||
|
|
||||||
export const Event = S.Event
|
export const Content = z
|
||||||
|
.object({
|
||||||
|
type: z.enum(["text", "binary"]),
|
||||||
|
content: z.string(),
|
||||||
|
diff: z.string().optional(),
|
||||||
|
patch: z
|
||||||
|
.object({
|
||||||
|
oldFileName: z.string(),
|
||||||
|
newFileName: z.string(),
|
||||||
|
oldHeader: z.string().optional(),
|
||||||
|
newHeader: z.string().optional(),
|
||||||
|
hunks: z.array(
|
||||||
|
z.object({
|
||||||
|
oldStart: z.number(),
|
||||||
|
oldLines: z.number(),
|
||||||
|
newStart: z.number(),
|
||||||
|
newLines: z.number(),
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
index: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
encoding: z.literal("base64").optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileContent",
|
||||||
|
})
|
||||||
|
export type Content = z.infer<typeof Content>
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export const Event = {
|
||||||
|
Edited: BusEvent.define(
|
||||||
|
"file.edited",
|
||||||
|
z.object({
|
||||||
|
file: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
export const Service = S.Service
|
const log = Log.create({ service: "file" })
|
||||||
export const layer = S.layer
|
|
||||||
|
const binary = new Set([
|
||||||
|
"exe",
|
||||||
|
"dll",
|
||||||
|
"pdb",
|
||||||
|
"bin",
|
||||||
|
"so",
|
||||||
|
"dylib",
|
||||||
|
"o",
|
||||||
|
"a",
|
||||||
|
"lib",
|
||||||
|
"wav",
|
||||||
|
"mp3",
|
||||||
|
"ogg",
|
||||||
|
"oga",
|
||||||
|
"ogv",
|
||||||
|
"ogx",
|
||||||
|
"flac",
|
||||||
|
"aac",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"weba",
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"webm",
|
||||||
|
"mkv",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"gzip",
|
||||||
|
"bz",
|
||||||
|
"bz2",
|
||||||
|
"bzip",
|
||||||
|
"bzip2",
|
||||||
|
"7z",
|
||||||
|
"rar",
|
||||||
|
"xz",
|
||||||
|
"lz",
|
||||||
|
"z",
|
||||||
|
"pdf",
|
||||||
|
"doc",
|
||||||
|
"docx",
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"xls",
|
||||||
|
"xlsx",
|
||||||
|
"dmg",
|
||||||
|
"iso",
|
||||||
|
"img",
|
||||||
|
"vmdk",
|
||||||
|
"ttf",
|
||||||
|
"otf",
|
||||||
|
"woff",
|
||||||
|
"woff2",
|
||||||
|
"eot",
|
||||||
|
"sqlite",
|
||||||
|
"db",
|
||||||
|
"mdb",
|
||||||
|
"apk",
|
||||||
|
"ipa",
|
||||||
|
"aab",
|
||||||
|
"xapk",
|
||||||
|
"app",
|
||||||
|
"pkg",
|
||||||
|
"deb",
|
||||||
|
"rpm",
|
||||||
|
"snap",
|
||||||
|
"flatpak",
|
||||||
|
"appimage",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"jar",
|
||||||
|
"war",
|
||||||
|
"ear",
|
||||||
|
"class",
|
||||||
|
"kotlin_module",
|
||||||
|
"dex",
|
||||||
|
"vdex",
|
||||||
|
"odex",
|
||||||
|
"oat",
|
||||||
|
"art",
|
||||||
|
"wasm",
|
||||||
|
"wat",
|
||||||
|
"bc",
|
||||||
|
"ll",
|
||||||
|
"s",
|
||||||
|
"ko",
|
||||||
|
"sys",
|
||||||
|
"drv",
|
||||||
|
"efi",
|
||||||
|
"rom",
|
||||||
|
"com",
|
||||||
|
])
|
||||||
|
|
||||||
|
const image = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"ico",
|
||||||
|
"tif",
|
||||||
|
"tiff",
|
||||||
|
"svg",
|
||||||
|
"svgz",
|
||||||
|
"avif",
|
||||||
|
"apng",
|
||||||
|
"jxl",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"raw",
|
||||||
|
"cr2",
|
||||||
|
"nef",
|
||||||
|
"arw",
|
||||||
|
"dng",
|
||||||
|
"orf",
|
||||||
|
"raf",
|
||||||
|
"pef",
|
||||||
|
"x3f",
|
||||||
|
])
|
||||||
|
|
||||||
|
const text = new Set([
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"mts",
|
||||||
|
"cts",
|
||||||
|
"mtsx",
|
||||||
|
"ctsx",
|
||||||
|
"js",
|
||||||
|
"jsx",
|
||||||
|
"mjs",
|
||||||
|
"cjs",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"fish",
|
||||||
|
"ps1",
|
||||||
|
"psm1",
|
||||||
|
"cmd",
|
||||||
|
"bat",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"less",
|
||||||
|
"graphql",
|
||||||
|
"gql",
|
||||||
|
"sql",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"conf",
|
||||||
|
"env",
|
||||||
|
])
|
||||||
|
|
||||||
|
const textName = new Set([
|
||||||
|
"dockerfile",
|
||||||
|
"makefile",
|
||||||
|
".gitignore",
|
||||||
|
".gitattributes",
|
||||||
|
".editorconfig",
|
||||||
|
".npmrc",
|
||||||
|
".nvmrc",
|
||||||
|
".prettierrc",
|
||||||
|
".eslintrc",
|
||||||
|
])
|
||||||
|
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
bmp: "image/bmp",
|
||||||
|
webp: "image/webp",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
tif: "image/tiff",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
svgz: "image/svg+xml",
|
||||||
|
avif: "image/avif",
|
||||||
|
apng: "image/apng",
|
||||||
|
jxl: "image/jxl",
|
||||||
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = { files: string[]; dirs: string[] }
|
||||||
|
|
||||||
|
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||||
|
const name = (file: string) => path.basename(file).toLowerCase()
|
||||||
|
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||||
|
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||||
|
const isTextByName = (file: string) => textName.has(name(file))
|
||||||
|
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||||
|
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||||
|
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||||
|
|
||||||
|
function shouldEncode(mimeType: string) {
|
||||||
|
const type = mimeType.toLowerCase()
|
||||||
|
log.debug("shouldEncode", { type })
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return false
|
||||||
|
if (type.includes("charset=")) return false
|
||||||
|
const top = type.split("/", 2)[0]
|
||||||
|
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = (item: string) => {
|
||||||
|
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||||
|
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||||
|
if (prefer) return items
|
||||||
|
const visible: string[] = []
|
||||||
|
const hiddenItems: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (hidden(item)) hiddenItems.push(item)
|
||||||
|
else visible.push(item)
|
||||||
|
}
|
||||||
|
return [...visible, ...hiddenItems]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
cache: Entry
|
||||||
|
fiber: Fiber.Fiber<void> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<File.Info[]>
|
||||||
|
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||||
|
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
||||||
|
readonly search: (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) => Effect.Effect<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("File.state")(() =>
|
||||||
|
Effect.succeed({
|
||||||
|
cache: { files: [], dirs: [] } as Entry,
|
||||||
|
fiber: undefined as Fiber.Fiber<void> | undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const scan = Effect.fn("File.scan")(function* () {
|
||||||
|
if (Instance.directory === path.parse(Instance.directory).root) return
|
||||||
|
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||||
|
const next: Entry = { files: [], dirs: [] }
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
if (isGlobalHome) {
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
const protectedNames = Protected.names()
|
||||||
|
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||||
|
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||||
|
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||||
|
const top = await fs.promises
|
||||||
|
.readdir(Instance.directory, { withFileTypes: true })
|
||||||
|
.catch(() => [] as fs.Dirent[])
|
||||||
|
|
||||||
|
for (const entry of top) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
if (shouldIgnoreName(entry.name)) continue
|
||||||
|
dirs.add(entry.name + "/")
|
||||||
|
|
||||||
|
const base = path.join(Instance.directory, entry.name)
|
||||||
|
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||||
|
for (const child of children) {
|
||||||
|
if (!child.isDirectory()) continue
|
||||||
|
if (shouldIgnoreNested(child.name)) continue
|
||||||
|
dirs.add(entry.name + "/" + child.name + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.dirs = Array.from(dirs).toSorted()
|
||||||
|
} else {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||||
|
next.files.push(file)
|
||||||
|
let current = file
|
||||||
|
while (true) {
|
||||||
|
const dir = path.dirname(current)
|
||||||
|
if (dir === ".") break
|
||||||
|
if (dir === current) break
|
||||||
|
current = dir
|
||||||
|
if (seen.has(dir)) continue
|
||||||
|
seen.add(dir)
|
||||||
|
next.dirs.push(dir + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
s.cache = next
|
||||||
|
})
|
||||||
|
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
const ensure = Effect.fn("File.ensure")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
if (!s.fiber)
|
||||||
|
s.fiber = yield* scan().pipe(
|
||||||
|
Effect.catchCause(() => Effect.void),
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
s.fiber = undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.forkIn(scope),
|
||||||
|
)
|
||||||
|
yield* Fiber.join(s.fiber)
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = Effect.fn("File.init")(function* () {
|
||||||
|
yield* ensure()
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("File.status")(function* () {
|
||||||
|
if (Instance.project.vcs !== "git") return []
|
||||||
|
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const diffOutput = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
|
||||||
|
const changed: File.Info[] = []
|
||||||
|
|
||||||
|
if (diffOutput.trim()) {
|
||||||
|
for (const line of diffOutput.trim().split("\n")) {
|
||||||
|
const [added, removed, file] = line.split("\t")
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: added === "-" ? 0 : parseInt(added, 10),
|
||||||
|
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||||
|
status: "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const untrackedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"ls-files",
|
||||||
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (untrackedOutput.trim()) {
|
||||||
|
for (const file of untrackedOutput.trim().split("\n")) {
|
||||||
|
try {
|
||||||
|
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: content.split("\n").length,
|
||||||
|
removed: 0,
|
||||||
|
status: "added",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedOutput = (
|
||||||
|
await git(
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"core.fsmonitor=false",
|
||||||
|
"-c",
|
||||||
|
"core.quotepath=false",
|
||||||
|
"diff",
|
||||||
|
"--name-only",
|
||||||
|
"--diff-filter=D",
|
||||||
|
"HEAD",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).text()
|
||||||
|
|
||||||
|
if (deletedOutput.trim()) {
|
||||||
|
for (const file of deletedOutput.trim().split("\n")) {
|
||||||
|
changed.push({
|
||||||
|
path: file,
|
||||||
|
added: 0,
|
||||||
|
removed: 0,
|
||||||
|
status: "deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed.map((item) => {
|
||||||
|
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
path: path.relative(Instance.directory, full),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const read = Effect.fn("File.read")(function* (file: string) {
|
||||||
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
|
using _ = log.time("read", { file })
|
||||||
|
const full = path.join(Instance.directory, file)
|
||||||
|
|
||||||
|
if (!Instance.containsPath(full)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImageByExtension(file)) {
|
||||||
|
if (await Filesystem.exists(full)) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: getImageMimeType(file),
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
|
if (isBinaryByExtension(file) && !knownText) {
|
||||||
|
return { type: "binary", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await Filesystem.exists(full))) {
|
||||||
|
return { type: "text", content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = Filesystem.mimeType(full)
|
||||||
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
|
if (encode && !isImage(mimeType)) {
|
||||||
|
return { type: "binary", content: "", mimeType }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encode) {
|
||||||
|
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
encoding: "base64",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||||
|
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
let diff = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||||
|
).text()
|
||||||
|
if (!diff.trim()) {
|
||||||
|
diff = (
|
||||||
|
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
})
|
||||||
|
).text()
|
||||||
|
}
|
||||||
|
if (diff.trim()) {
|
||||||
|
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||||
|
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||||
|
context: Infinity,
|
||||||
|
ignoreWhitespace: true,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
content,
|
||||||
|
patch,
|
||||||
|
diff: formatPatch(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "text", content }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const exclude = [".git", ".DS_Store"]
|
||||||
|
let ignored = (_: string) => false
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
const ig = ignore()
|
||||||
|
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||||
|
if (await Filesystem.exists(gitignore)) {
|
||||||
|
ig.add(await Filesystem.readText(gitignore))
|
||||||
|
}
|
||||||
|
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||||
|
if (await Filesystem.exists(ignoreFile)) {
|
||||||
|
ig.add(await Filesystem.readText(ignoreFile))
|
||||||
|
}
|
||||||
|
ignored = ig.ignores.bind(ig)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||||
|
if (!Instance.containsPath(resolved)) {
|
||||||
|
throw new Error("Access denied: path escapes project directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: File.Node[] = []
|
||||||
|
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
if (exclude.includes(entry.name)) continue
|
||||||
|
const absolute = path.join(resolved, entry.name)
|
||||||
|
const file = path.relative(Instance.directory, absolute)
|
||||||
|
const type = entry.isDirectory() ? "directory" : "file"
|
||||||
|
nodes.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: file,
|
||||||
|
absolute,
|
||||||
|
type,
|
||||||
|
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const search = Effect.fn("File.search")(function* (input: {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
dirs?: boolean
|
||||||
|
type?: "file" | "directory"
|
||||||
|
}) {
|
||||||
|
yield* ensure()
|
||||||
|
const { cache } = yield* InstanceState.get(state)
|
||||||
|
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const query = input.query.trim()
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||||
|
log.info("search", { query, kind })
|
||||||
|
|
||||||
|
const result = cache
|
||||||
|
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
if (kind === "file") return result.files.slice(0, limit)
|
||||||
|
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items =
|
||||||
|
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||||
|
|
||||||
|
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||||
|
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||||
|
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||||
|
|
||||||
|
log.info("search", { query, kind, results: output.length })
|
||||||
|
return output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info("init")
|
||||||
|
return Service.of({ init, status, read, list, search })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.init()))
|
return runPromise((svc) => svc.init())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.status()))
|
return runPromise((svc) => svc.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read(file: string): Promise<Content> {
|
export async function read(file: string): Promise<Content> {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
|
return runPromise((svc) => svc.read(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(dir?: string) {
|
export async function list(dir?: string) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
|
return runPromise((svc) => svc.list(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
|
return runPromise((svc) => svc.search(input))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,674 +0,0 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { git } from "@/util/git"
|
|
||||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
|
||||||
import fs from "fs"
|
|
||||||
import fuzzysort from "fuzzysort"
|
|
||||||
import ignore from "ignore"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Protected } from "./protected"
|
|
||||||
import { Ripgrep } from "./ripgrep"
|
|
||||||
|
|
||||||
export namespace File {
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
path: z.string(),
|
|
||||||
added: z.number().int(),
|
|
||||||
removed: z.number().int(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "File",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Node = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
absolute: z.string(),
|
|
||||||
type: z.enum(["file", "directory"]),
|
|
||||||
ignored: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileNode",
|
|
||||||
})
|
|
||||||
export type Node = z.infer<typeof Node>
|
|
||||||
|
|
||||||
export const Content = z
|
|
||||||
.object({
|
|
||||||
type: z.enum(["text", "binary"]),
|
|
||||||
content: z.string(),
|
|
||||||
diff: z.string().optional(),
|
|
||||||
patch: z
|
|
||||||
.object({
|
|
||||||
oldFileName: z.string(),
|
|
||||||
newFileName: z.string(),
|
|
||||||
oldHeader: z.string().optional(),
|
|
||||||
newHeader: z.string().optional(),
|
|
||||||
hunks: z.array(
|
|
||||||
z.object({
|
|
||||||
oldStart: z.number(),
|
|
||||||
oldLines: z.number(),
|
|
||||||
newStart: z.number(),
|
|
||||||
newLines: z.number(),
|
|
||||||
lines: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
index: z.string().optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
encoding: z.literal("base64").optional(),
|
|
||||||
mimeType: z.string().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileContent",
|
|
||||||
})
|
|
||||||
export type Content = z.infer<typeof Content>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Edited: BusEvent.define(
|
|
||||||
"file.edited",
|
|
||||||
z.object({
|
|
||||||
file: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = Log.create({ service: "file" })
|
|
||||||
|
|
||||||
const binary = new Set([
|
|
||||||
"exe",
|
|
||||||
"dll",
|
|
||||||
"pdb",
|
|
||||||
"bin",
|
|
||||||
"so",
|
|
||||||
"dylib",
|
|
||||||
"o",
|
|
||||||
"a",
|
|
||||||
"lib",
|
|
||||||
"wav",
|
|
||||||
"mp3",
|
|
||||||
"ogg",
|
|
||||||
"oga",
|
|
||||||
"ogv",
|
|
||||||
"ogx",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"wma",
|
|
||||||
"m4a",
|
|
||||||
"weba",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"mkv",
|
|
||||||
"zip",
|
|
||||||
"tar",
|
|
||||||
"gz",
|
|
||||||
"gzip",
|
|
||||||
"bz",
|
|
||||||
"bz2",
|
|
||||||
"bzip",
|
|
||||||
"bzip2",
|
|
||||||
"7z",
|
|
||||||
"rar",
|
|
||||||
"xz",
|
|
||||||
"lz",
|
|
||||||
"z",
|
|
||||||
"pdf",
|
|
||||||
"doc",
|
|
||||||
"docx",
|
|
||||||
"ppt",
|
|
||||||
"pptx",
|
|
||||||
"xls",
|
|
||||||
"xlsx",
|
|
||||||
"dmg",
|
|
||||||
"iso",
|
|
||||||
"img",
|
|
||||||
"vmdk",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
"woff",
|
|
||||||
"woff2",
|
|
||||||
"eot",
|
|
||||||
"sqlite",
|
|
||||||
"db",
|
|
||||||
"mdb",
|
|
||||||
"apk",
|
|
||||||
"ipa",
|
|
||||||
"aab",
|
|
||||||
"xapk",
|
|
||||||
"app",
|
|
||||||
"pkg",
|
|
||||||
"deb",
|
|
||||||
"rpm",
|
|
||||||
"snap",
|
|
||||||
"flatpak",
|
|
||||||
"appimage",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"jar",
|
|
||||||
"war",
|
|
||||||
"ear",
|
|
||||||
"class",
|
|
||||||
"kotlin_module",
|
|
||||||
"dex",
|
|
||||||
"vdex",
|
|
||||||
"odex",
|
|
||||||
"oat",
|
|
||||||
"art",
|
|
||||||
"wasm",
|
|
||||||
"wat",
|
|
||||||
"bc",
|
|
||||||
"ll",
|
|
||||||
"s",
|
|
||||||
"ko",
|
|
||||||
"sys",
|
|
||||||
"drv",
|
|
||||||
"efi",
|
|
||||||
"rom",
|
|
||||||
"com",
|
|
||||||
"cmd",
|
|
||||||
"ps1",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
])
|
|
||||||
|
|
||||||
const image = new Set([
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
"tif",
|
|
||||||
"tiff",
|
|
||||||
"svg",
|
|
||||||
"svgz",
|
|
||||||
"avif",
|
|
||||||
"apng",
|
|
||||||
"jxl",
|
|
||||||
"heic",
|
|
||||||
"heif",
|
|
||||||
"raw",
|
|
||||||
"cr2",
|
|
||||||
"nef",
|
|
||||||
"arw",
|
|
||||||
"dng",
|
|
||||||
"orf",
|
|
||||||
"raf",
|
|
||||||
"pef",
|
|
||||||
"x3f",
|
|
||||||
])
|
|
||||||
|
|
||||||
const text = new Set([
|
|
||||||
"ts",
|
|
||||||
"tsx",
|
|
||||||
"mts",
|
|
||||||
"cts",
|
|
||||||
"mtsx",
|
|
||||||
"ctsx",
|
|
||||||
"js",
|
|
||||||
"jsx",
|
|
||||||
"mjs",
|
|
||||||
"cjs",
|
|
||||||
"sh",
|
|
||||||
"bash",
|
|
||||||
"zsh",
|
|
||||||
"fish",
|
|
||||||
"ps1",
|
|
||||||
"psm1",
|
|
||||||
"cmd",
|
|
||||||
"bat",
|
|
||||||
"json",
|
|
||||||
"jsonc",
|
|
||||||
"json5",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"toml",
|
|
||||||
"md",
|
|
||||||
"mdx",
|
|
||||||
"txt",
|
|
||||||
"xml",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"sass",
|
|
||||||
"less",
|
|
||||||
"graphql",
|
|
||||||
"gql",
|
|
||||||
"sql",
|
|
||||||
"ini",
|
|
||||||
"cfg",
|
|
||||||
"conf",
|
|
||||||
"env",
|
|
||||||
])
|
|
||||||
|
|
||||||
const textName = new Set([
|
|
||||||
"dockerfile",
|
|
||||||
"makefile",
|
|
||||||
".gitignore",
|
|
||||||
".gitattributes",
|
|
||||||
".editorconfig",
|
|
||||||
".npmrc",
|
|
||||||
".nvmrc",
|
|
||||||
".prettierrc",
|
|
||||||
".eslintrc",
|
|
||||||
])
|
|
||||||
|
|
||||||
const mime: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
bmp: "image/bmp",
|
|
||||||
webp: "image/webp",
|
|
||||||
ico: "image/x-icon",
|
|
||||||
tif: "image/tiff",
|
|
||||||
tiff: "image/tiff",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
svgz: "image/svg+xml",
|
|
||||||
avif: "image/avif",
|
|
||||||
apng: "image/apng",
|
|
||||||
jxl: "image/jxl",
|
|
||||||
heic: "image/heic",
|
|
||||||
heif: "image/heif",
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry = { files: string[]; dirs: string[] }
|
|
||||||
|
|
||||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
|
||||||
const name = (file: string) => path.basename(file).toLowerCase()
|
|
||||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
|
||||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
|
||||||
const isTextByName = (file: string) => textName.has(name(file))
|
|
||||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
|
||||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
|
||||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
|
||||||
|
|
||||||
function shouldEncode(mimeType: string) {
|
|
||||||
const type = mimeType.toLowerCase()
|
|
||||||
log.info("shouldEncode", { type })
|
|
||||||
if (!type) return false
|
|
||||||
if (type.startsWith("text/")) return false
|
|
||||||
if (type.includes("charset=")) return false
|
|
||||||
const top = type.split("/", 2)[0]
|
|
||||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hidden = (item: string) => {
|
|
||||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
||||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
|
||||||
if (prefer) return items
|
|
||||||
const visible: string[] = []
|
|
||||||
const hiddenItems: string[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (hidden(item)) hiddenItems.push(item)
|
|
||||||
else visible.push(item)
|
|
||||||
}
|
|
||||||
return [...visible, ...hiddenItems]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<File.Info[]>
|
|
||||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
|
||||||
readonly list: (dir?: string) => Effect.Effect<File.Node[]>
|
|
||||||
readonly search: (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) => Effect.Effect<string[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
let cache: Entry = { files: [], dirs: [] }
|
|
||||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
|
||||||
|
|
||||||
const scan = Effect.fn("File.scan")(function* () {
|
|
||||||
if (instance.directory === path.parse(instance.directory).root) return
|
|
||||||
const next: Entry = { files: [], dirs: [] }
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
if (isGlobalHome) {
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
const protectedNames = Protected.names()
|
|
||||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
|
||||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
|
||||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
|
||||||
const top = await fs.promises
|
|
||||||
.readdir(instance.directory, { withFileTypes: true })
|
|
||||||
.catch(() => [] as fs.Dirent[])
|
|
||||||
|
|
||||||
for (const entry of top) {
|
|
||||||
if (!entry.isDirectory()) continue
|
|
||||||
if (shouldIgnoreName(entry.name)) continue
|
|
||||||
dirs.add(entry.name + "/")
|
|
||||||
|
|
||||||
const base = path.join(instance.directory, entry.name)
|
|
||||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
|
||||||
for (const child of children) {
|
|
||||||
if (!child.isDirectory()) continue
|
|
||||||
if (shouldIgnoreNested(child.name)) continue
|
|
||||||
dirs.add(entry.name + "/" + child.name + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.dirs = Array.from(dirs).toSorted()
|
|
||||||
} else {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
|
||||||
next.files.push(file)
|
|
||||||
let current = file
|
|
||||||
while (true) {
|
|
||||||
const dir = path.dirname(current)
|
|
||||||
if (dir === ".") break
|
|
||||||
if (dir === current) break
|
|
||||||
current = dir
|
|
||||||
if (seen.has(dir)) continue
|
|
||||||
seen.add(dir)
|
|
||||||
next.dirs.push(dir + "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
cache = next
|
|
||||||
})
|
|
||||||
|
|
||||||
const getFiles = () => cache
|
|
||||||
|
|
||||||
const scope = yield* Scope.Scope
|
|
||||||
let fiber: Fiber.Fiber<void> | undefined
|
|
||||||
|
|
||||||
const init = Effect.fn("File.init")(function* () {
|
|
||||||
if (!fiber) {
|
|
||||||
fiber = yield* scan().pipe(
|
|
||||||
Effect.catchCause(() => Effect.void),
|
|
||||||
Effect.forkIn(scope),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
yield* Fiber.join(fiber)
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("File.status")(function* () {
|
|
||||||
if (instance.project.vcs !== "git") return []
|
|
||||||
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const diffOutput = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
|
||||||
cwd: instance.directory,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
|
|
||||||
const changed: File.Info[] = []
|
|
||||||
|
|
||||||
if (diffOutput.trim()) {
|
|
||||||
for (const line of diffOutput.trim().split("\n")) {
|
|
||||||
const [added, removed, file] = line.split("\t")
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: added === "-" ? 0 : parseInt(added, 10),
|
|
||||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
|
||||||
status: "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const untrackedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"ls-files",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (untrackedOutput.trim()) {
|
|
||||||
for (const file of untrackedOutput.trim().split("\n")) {
|
|
||||||
try {
|
|
||||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: content.split("\n").length,
|
|
||||||
removed: 0,
|
|
||||||
status: "added",
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedOutput = (
|
|
||||||
await git(
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"core.fsmonitor=false",
|
|
||||||
"-c",
|
|
||||||
"core.quotepath=false",
|
|
||||||
"diff",
|
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=D",
|
|
||||||
"HEAD",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: instance.directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).text()
|
|
||||||
|
|
||||||
if (deletedOutput.trim()) {
|
|
||||||
for (const file of deletedOutput.trim().split("\n")) {
|
|
||||||
changed.push({
|
|
||||||
path: file,
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
status: "deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed.map((item) => {
|
|
||||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
path: path.relative(instance.directory, full),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const read = Effect.fn("File.read")(function* (file: string) {
|
|
||||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
|
||||||
using _ = log.time("read", { file })
|
|
||||||
const full = path.join(instance.directory, file)
|
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
|
||||||
if (await Filesystem.exists(full)) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) {
|
|
||||||
return { type: "binary", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await Filesystem.exists(full))) {
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
|
||||||
return { type: "binary", content: "", mimeType }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encode) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType,
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
|
||||||
|
|
||||||
if (instance.project.vcs === "git") {
|
|
||||||
let diff = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
|
|
||||||
).text()
|
|
||||||
if (!diff.trim()) {
|
|
||||||
diff = (
|
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
|
||||||
cwd: instance.directory,
|
|
||||||
})
|
|
||||||
).text()
|
|
||||||
}
|
|
||||||
if (diff.trim()) {
|
|
||||||
const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
|
|
||||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
|
||||||
context: Infinity,
|
|
||||||
ignoreWhitespace: true,
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
type: "text",
|
|
||||||
content,
|
|
||||||
patch,
|
|
||||||
diff: formatPatch(patch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: "text", content }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const exclude = [".git", ".DS_Store"]
|
|
||||||
let ignored = (_: string) => false
|
|
||||||
if (instance.project.vcs === "git") {
|
|
||||||
const ig = ignore()
|
|
||||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
|
||||||
if (await Filesystem.exists(gitignore)) {
|
|
||||||
ig.add(await Filesystem.readText(gitignore))
|
|
||||||
}
|
|
||||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
|
||||||
if (await Filesystem.exists(ignoreFile)) {
|
|
||||||
ig.add(await Filesystem.readText(ignoreFile))
|
|
||||||
}
|
|
||||||
ignored = ig.ignores.bind(ig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
|
||||||
if (!Instance.containsPath(resolved)) {
|
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: File.Node[] = []
|
|
||||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
|
||||||
if (exclude.includes(entry.name)) continue
|
|
||||||
const absolute = path.join(resolved, entry.name)
|
|
||||||
const file = path.relative(instance.directory, absolute)
|
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
|
||||||
nodes.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: file,
|
|
||||||
absolute,
|
|
||||||
type,
|
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const search = Effect.fn("File.search")(function* (input: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
dirs?: boolean
|
|
||||||
type?: "file" | "directory"
|
|
||||||
}) {
|
|
||||||
return yield* Effect.promise(async () => {
|
|
||||||
const query = input.query.trim()
|
|
||||||
const limit = input.limit ?? 100
|
|
||||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
|
||||||
log.info("search", { query, kind })
|
|
||||||
|
|
||||||
const result = getFiles()
|
|
||||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
if (kind === "file") return result.files.slice(0, limit)
|
|
||||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items =
|
|
||||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
|
||||||
|
|
||||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
|
||||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
|
||||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
|
||||||
|
|
||||||
log.info("search", { query, kind, results: output.length })
|
|
||||||
return output
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
log.info("init")
|
|
||||||
return Service.of({ init, status, read, list, search })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import type { SessionID } from "@/session/schema"
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace FileTime {
|
|
||||||
const log = Log.create({ service: "file.time" })
|
|
||||||
|
|
||||||
export type Stamp = {
|
|
||||||
readonly read: Date
|
|
||||||
readonly mtime: number | undefined
|
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
|
||||||
const stat = Filesystem.stat(file)
|
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
|
||||||
const value = reads.get(sessionID)
|
|
||||||
if (value) return value
|
|
||||||
|
|
||||||
const next = new Map<string, Stamp>()
|
|
||||||
reads.set(sessionID, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
|
||||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
|
||||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
|
||||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
|
||||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
|
||||||
const locks = new Map<string, Semaphore.Semaphore>()
|
|
||||||
|
|
||||||
const getLock = (filepath: string) => {
|
|
||||||
const lock = locks.get(filepath)
|
|
||||||
if (lock) return lock
|
|
||||||
|
|
||||||
const next = Semaphore.makeUnsafe(1)
|
|
||||||
locks.set(filepath, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
|
||||||
log.info("read", { sessionID, file })
|
|
||||||
session(reads, sessionID).set(file, yield* stamp(file))
|
|
||||||
})
|
|
||||||
|
|
||||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
|
||||||
return reads.get(sessionID)?.get(file)?.read
|
|
||||||
})
|
|
||||||
|
|
||||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
|
||||||
if (disableCheck) return
|
|
||||||
|
|
||||||
const time = reads.get(sessionID)?.get(filepath)
|
|
||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
|
||||||
if (!changed) return
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
|
||||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ read, get, assert, withLock })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.orDie, Layer.fresh)
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,128 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { FileTime as S } from "./time-service"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace FileTime {
|
export namespace FileTime {
|
||||||
export type Stamp = S.Stamp
|
const log = Log.create({ service: "file.time" })
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export type Stamp = {
|
||||||
|
readonly read: Date
|
||||||
|
readonly mtime: number | undefined
|
||||||
|
readonly ctime: number | undefined
|
||||||
|
readonly size: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export const Service = S.Service
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
export const layer = S.layer
|
const stat = Filesystem.stat(file)
|
||||||
|
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: stat?.mtime?.getTime(),
|
||||||
|
ctime: stat?.ctime?.getTime(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
|
const value = reads.get(sessionID)
|
||||||
|
if (value) return value
|
||||||
|
|
||||||
|
const next = new Map<string, Stamp>()
|
||||||
|
reads.set(sessionID, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
reads: Map<SessionID, Map<string, Stamp>>
|
||||||
|
locks: Map<string, Semaphore.Semaphore>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||||
|
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||||
|
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||||
|
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("FileTime.state")(() =>
|
||||||
|
Effect.succeed({
|
||||||
|
reads: new Map<SessionID, Map<string, Stamp>>(),
|
||||||
|
locks: new Map<string, Semaphore.Semaphore>(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
|
||||||
|
const locks = (yield* InstanceState.get(state)).locks
|
||||||
|
const lock = locks.get(filepath)
|
||||||
|
if (lock) return lock
|
||||||
|
|
||||||
|
const next = Semaphore.makeUnsafe(1)
|
||||||
|
locks.set(filepath, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
log.info("read", { sessionID, file })
|
||||||
|
session(reads, sessionID).set(file, yield* stamp(file))
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
return reads.get(sessionID)?.get(file)?.read
|
||||||
|
})
|
||||||
|
|
||||||
|
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||||
|
if (disableCheck) return
|
||||||
|
|
||||||
|
const reads = (yield* InstanceState.get(state)).reads
|
||||||
|
const time = reads.get(sessionID)?.get(filepath)
|
||||||
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
|
const next = yield* stamp(filepath)
|
||||||
|
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||||
|
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ read, get, assert, withLock })
|
||||||
|
}),
|
||||||
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
|
return runPromise((s) => s.read(sessionID, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
export function get(sessionID: SessionID, file: string) {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
|
return runPromise((s) => s.get(sessionID, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
export async function assert(sessionID: SessionID, filepath: string) {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
|
return runPromise((s) => s.assert(sessionID, filepath))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
|
return runPromise((s) => s.withLock(filepath, fn))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||||
import type ParcelWatcher from "@parcel/watcher"
|
import type ParcelWatcher from "@parcel/watcher"
|
||||||
@@ -7,7 +7,8 @@ import path from "path"
|
|||||||
import z from "zod"
|
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 { InstanceContext } from "@/effect/instance-context"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
@@ -60,82 +61,107 @@ export namespace FileWatcher {
|
|||||||
|
|
||||||
export const hasNativeBinding = () => !!watcher()
|
export const hasNativeBinding = () => !!watcher()
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const state = yield* InstanceState.make(
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
Effect.fn("FileWatcher.state")(
|
||||||
|
function* () {
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||||
|
|
||||||
log.info("init", { directory: instance.directory })
|
log.info("init", { directory: Instance.directory })
|
||||||
|
|
||||||
const backend = getBackend()
|
const backend = getBackend()
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||||
return Service.of({})
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = watcher()
|
const w = watcher()
|
||||||
if (!w) return Service.of({})
|
if (!w) return
|
||||||
|
|
||||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||||
|
|
||||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||||
|
)
|
||||||
|
|
||||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||||
if (err) return
|
if (err) return
|
||||||
for (const evt of evts) {
|
for (const evt of evts) {
|
||||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribe = (dir: string, ignore: string[]) => {
|
const subscribe = (dir: string, ignore: string[]) => {
|
||||||
const pending = w.subscribe(dir, cb, { ignore, backend })
|
const pending = w.subscribe(dir, cb, { ignore, backend })
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const sub = yield* Effect.promise(() => pending)
|
const sub = yield* Effect.promise(() => pending)
|
||||||
subs.push(sub)
|
subs.push(sub)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||||
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||||
|
|
||||||
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||||
|
yield* subscribe(Instance.directory, [
|
||||||
|
...FileIgnore.PATTERNS,
|
||||||
|
...cfgIgnores,
|
||||||
|
...protecteds(Instance.directory),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Instance.project.vcs === "git") {
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
git(["rev-parse", "--git-dir"], {
|
||||||
|
cwd: Instance.project.worktree,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const vcsDir =
|
||||||
|
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||||
|
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||||
|
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||||
|
(entry) => entry !== "HEAD",
|
||||||
|
)
|
||||||
|
yield* subscribe(vcsDir, ignore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
|
||||||
return Effect.void
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
return Service.of({
|
||||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
init: Effect.fn("FileWatcher.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (instance.project.vcs === "git") {
|
export function init() {
|
||||||
const result = yield* Effect.promise(() =>
|
return runPromise((svc) => svc.init())
|
||||||
git(["rev-parse", "--git-dir"], {
|
}
|
||||||
cwd: instance.project.worktree,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
|
|
||||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
|
||||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
|
||||||
(entry) => entry !== "HEAD",
|
|
||||||
)
|
|
||||||
yield* subscribe(vcsDir, ignore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Service.of({})
|
|
||||||
}).pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.succeed(Service.of({}))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).pipe(Layer.orDie, Layer.fresh)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
|
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
||||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||||
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
export const OPENCODE_STRICT_CONFIG_DEPS = truthy("OPENCODE_STRICT_CONFIG_DEPS")
|
||||||
|
|||||||
@@ -1,16 +1,182 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Format as S } from "./service"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import path from "path"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
import z from "zod"
|
||||||
|
import { Bus } from "../bus"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { File } from "../file"
|
||||||
|
import { Instance } from "../project/instance"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import * as Formatter from "./formatter"
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
export const Status = S.Status
|
const log = Log.create({ service: "format" })
|
||||||
export type Status = S.Status
|
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export const Status = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
extensions: z.string().array(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FormatterStatus",
|
||||||
|
})
|
||||||
|
export type Status = z.infer<typeof Status>
|
||||||
|
|
||||||
export const Service = S.Service
|
export interface Interface {
|
||||||
export const layer = S.layer
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("Format.state")(function* (_ctx) {
|
||||||
|
const enabled: Record<string, boolean> = {}
|
||||||
|
const formatters: Record<string, Formatter.Info> = {}
|
||||||
|
|
||||||
|
const cfg = yield* Effect.promise(() => Config.get())
|
||||||
|
|
||||||
|
if (cfg.formatter !== false) {
|
||||||
|
for (const item of Object.values(Formatter)) {
|
||||||
|
formatters[item.name] = item
|
||||||
|
}
|
||||||
|
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
||||||
|
if (item.disabled) {
|
||||||
|
delete formatters[name]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const info = mergeDeep(formatters[name] ?? {}, {
|
||||||
|
command: [],
|
||||||
|
extensions: [],
|
||||||
|
...item,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (info.command.length === 0) continue
|
||||||
|
|
||||||
|
formatters[name] = {
|
||||||
|
...info,
|
||||||
|
name,
|
||||||
|
enabled: async () => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("all formatters are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEnabled(item: Formatter.Info) {
|
||||||
|
let status = enabled[item.name]
|
||||||
|
if (status === undefined) {
|
||||||
|
status = await item.enabled()
|
||||||
|
enabled[item.name] = status
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFormatter(ext: string) {
|
||||||
|
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
|
||||||
|
const checks = await Promise.all(
|
||||||
|
matching.map(async (item) => {
|
||||||
|
log.info("checking", { name: item.name, ext })
|
||||||
|
const on = await isEnabled(item)
|
||||||
|
if (on) {
|
||||||
|
log.info("enabled", { name: item.name, ext })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
enabled: on,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bus.subscribe(
|
||||||
|
File.Event.Edited,
|
||||||
|
Instance.bind(async (payload) => {
|
||||||
|
const file = payload.properties.file
|
||||||
|
log.info("formatting", { file })
|
||||||
|
const ext = path.extname(file)
|
||||||
|
|
||||||
|
for (const item of await getFormatter(ext)) {
|
||||||
|
log.info("running", { command: item.command })
|
||||||
|
try {
|
||||||
|
const proc = Process.spawn(
|
||||||
|
item.command.map((x) => x.replace("$FILE", file)),
|
||||||
|
{
|
||||||
|
cwd: Instance.directory,
|
||||||
|
env: { ...process.env, ...item.environment },
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const exit = await proc.exited
|
||||||
|
if (exit !== 0) {
|
||||||
|
log.error("failed", {
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("failed to format file", {
|
||||||
|
error,
|
||||||
|
command: item.command,
|
||||||
|
...item.environment,
|
||||||
|
file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
|
)
|
||||||
|
log.info("init")
|
||||||
|
|
||||||
|
return {
|
||||||
|
formatters,
|
||||||
|
isEnabled,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const init = Effect.fn("Format.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("Format.status")(function* () {
|
||||||
|
const { formatters, isEnabled } = yield* InstanceState.get(state)
|
||||||
|
const result: Status[] = []
|
||||||
|
for (const formatter of Object.values(formatters)) {
|
||||||
|
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||||
|
result.push({
|
||||||
|
name: formatter.name,
|
||||||
|
extensions: formatter.extensions,
|
||||||
|
enabled: isOn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ init, status })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
return runPromise((s) => s.init())
|
||||||
|
}
|
||||||
|
|
||||||
export async function status() {
|
export async function status() {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.status()))
|
return runPromise((s) => s.status())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import path from "path"
|
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import z from "zod"
|
|
||||||
import { Bus } from "../bus"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { File } from "../file/service"
|
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Process } from "../util/process"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import * as Formatter from "./formatter"
|
|
||||||
|
|
||||||
export namespace Format {
|
|
||||||
const log = Log.create({ service: "format" })
|
|
||||||
|
|
||||||
export const Status = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
extensions: z.string().array(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FormatterStatus",
|
|
||||||
})
|
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
|
|
||||||
const enabled: Record<string, boolean> = {}
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
|
|
||||||
const cfg = yield* Effect.promise(() => Config.get())
|
|
||||||
|
|
||||||
if (cfg.formatter !== false) {
|
|
||||||
for (const item of Object.values(Formatter)) {
|
|
||||||
formatters[item.name] = item
|
|
||||||
}
|
|
||||||
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
|
|
||||||
if (item.disabled) {
|
|
||||||
delete formatters[name]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const info = mergeDeep(formatters[name] ?? {}, {
|
|
||||||
command: [],
|
|
||||||
extensions: [],
|
|
||||||
...item,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (info.command.length === 0) continue
|
|
||||||
|
|
||||||
formatters[name] = {
|
|
||||||
...info,
|
|
||||||
name,
|
|
||||||
enabled: async () => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.info("all formatters are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isEnabled(item: Formatter.Info) {
|
|
||||||
let status = enabled[item.name]
|
|
||||||
if (status === undefined) {
|
|
||||||
status = await item.enabled()
|
|
||||||
enabled[item.name] = status
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFormatter(ext: string) {
|
|
||||||
const result = []
|
|
||||||
for (const item of Object.values(formatters)) {
|
|
||||||
log.info("checking", { name: item.name, ext })
|
|
||||||
if (!item.extensions.includes(ext)) continue
|
|
||||||
if (!(await isEnabled(item))) continue
|
|
||||||
log.info("enabled", { name: item.name, ext })
|
|
||||||
result.push(item)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() =>
|
|
||||||
Bus.subscribe(
|
|
||||||
File.Event.Edited,
|
|
||||||
Instance.bind(async (payload) => {
|
|
||||||
const file = payload.properties.file
|
|
||||||
log.info("formatting", { file })
|
|
||||||
const ext = path.extname(file)
|
|
||||||
|
|
||||||
for (const item of await getFormatter(ext)) {
|
|
||||||
log.info("running", { command: item.command })
|
|
||||||
try {
|
|
||||||
const proc = Process.spawn(
|
|
||||||
item.command.map((x) => x.replace("$FILE", file)),
|
|
||||||
{
|
|
||||||
cwd: instance.directory,
|
|
||||||
env: { ...process.env, ...item.environment },
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const exit = await proc.exited
|
|
||||||
if (exit !== 0) {
|
|
||||||
log.error("failed", {
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("failed to format file", {
|
|
||||||
error,
|
|
||||||
command: item.command,
|
|
||||||
...item.environment,
|
|
||||||
file,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
|
||||||
)
|
|
||||||
log.info("init")
|
|
||||||
|
|
||||||
const status = Effect.fn("Format.status")(function* () {
|
|
||||||
const result: Status[] = []
|
|
||||||
for (const formatter of Object.values(formatters)) {
|
|
||||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
|
||||||
result.push({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: formatter.extensions,
|
|
||||||
enabled: isOn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ status })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
@@ -293,7 +294,7 @@ export namespace Installation {
|
|||||||
result = yield* run(["scoop", "install", `opencode@${target}`])
|
result = yield* run(["scoop", "install", `opencode@${target}`])
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${m}`)
|
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
||||||
}
|
}
|
||||||
if (!result || result.code !== 0) {
|
if (!result || result.code !== 0) {
|
||||||
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
|
||||||
@@ -329,27 +330,21 @@ export namespace Installation {
|
|||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Legacy adapters — dynamic import avoids circular dependency since
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
// foundational modules (db.ts, provider/models.ts) import Installation
|
|
||||||
// at load time, and runtime transitively loads those same modules.
|
|
||||||
async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
|
|
||||||
const { runtime } = await import("@/effect/runtime")
|
|
||||||
return runtime.runPromise(Service.use(f))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function info(): Promise<Info> {
|
export async function info(): Promise<Info> {
|
||||||
return runPromise((svc) => svc.info())
|
return runPromise((svc) => svc.info())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function method(): Promise<Method> {
|
export async function method(): Promise<Method> {
|
||||||
return runPromise((svc) => svc.method())
|
return runPromise((svc) => svc.method())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function latest(installMethod?: Method): Promise<string> {
|
export async function latest(installMethod?: Method): Promise<string> {
|
||||||
return runPromise((svc) => svc.latest(installMethod))
|
return runPromise((svc) => svc.latest(installMethod))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upgrade(m: Method, target: string): Promise<void> {
|
export async function upgrade(m: Method, target: string): Promise<void> {
|
||||||
return runPromise((svc) => svc.upgrade(m, target))
|
return runPromise((svc) => svc.upgrade(m, target))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,322 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { Bus } from "@/bus"
|
||||||
import { fn } from "@/util/fn"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { ProjectID } from "@/project/schema"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { PermissionTable } from "@/session/session.sql"
|
||||||
|
import { Database, eq } from "@/storage/db"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { Wildcard } from "@/util/wildcard"
|
||||||
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
|
import os from "os"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Permission as S } from "./service"
|
import { evaluate as evalRule } from "./evaluate"
|
||||||
|
import { PermissionID } from "./schema"
|
||||||
|
|
||||||
export namespace PermissionNext {
|
export namespace Permission {
|
||||||
export const Action = S.Action
|
const log = Log.create({ service: "permission" })
|
||||||
export type Action = S.Action
|
|
||||||
|
|
||||||
export const Rule = S.Rule
|
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||||
export type Rule = S.Rule
|
ref: "PermissionAction",
|
||||||
|
})
|
||||||
|
export type Action = z.infer<typeof Action>
|
||||||
|
|
||||||
export const Ruleset = S.Ruleset
|
export const Rule = z
|
||||||
export type Ruleset = S.Ruleset
|
.object({
|
||||||
|
permission: z.string(),
|
||||||
|
pattern: z.string(),
|
||||||
|
action: Action,
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRule",
|
||||||
|
})
|
||||||
|
export type Rule = z.infer<typeof Rule>
|
||||||
|
|
||||||
export const Request = S.Request
|
export const Ruleset = Rule.array().meta({
|
||||||
export type Request = S.Request
|
ref: "PermissionRuleset",
|
||||||
|
})
|
||||||
|
export type Ruleset = z.infer<typeof Ruleset>
|
||||||
|
|
||||||
export const Reply = S.Reply
|
export const Request = z
|
||||||
export type Reply = S.Reply
|
.object({
|
||||||
|
id: PermissionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
permission: z.string(),
|
||||||
|
patterns: z.string().array(),
|
||||||
|
metadata: z.record(z.string(), z.any()),
|
||||||
|
always: z.string().array(),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "PermissionRequest",
|
||||||
|
})
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
export const Approval = S.Approval
|
export const Reply = z.enum(["once", "always", "reject"])
|
||||||
export type Approval = z.infer<typeof S.Approval>
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
export const Event = S.Event
|
export const Approval = z.object({
|
||||||
|
projectID: ProjectID.zod,
|
||||||
|
patterns: z.string().array(),
|
||||||
|
})
|
||||||
|
|
||||||
export const RejectedError = S.RejectedError
|
export const Event = {
|
||||||
export const CorrectedError = S.CorrectedError
|
Asked: BusEvent.define("permission.asked", Request),
|
||||||
export const DeniedError = S.DeniedError
|
Replied: BusEvent.define(
|
||||||
export type Error = S.Error
|
"permission.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
export const AskInput = S.AskInput
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||||
export const ReplyInput = S.ReplyInput
|
override get message() {
|
||||||
|
return "The user rejected permission to use this specific tool call."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||||
|
feedback: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Service = S.Service
|
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||||
export const layer = S.layer
|
ruleset: Schema.Any,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const evaluate = S.evaluate
|
export type Error = DeniedError | RejectedError | CorrectedError
|
||||||
export const fromConfig = S.fromConfig
|
|
||||||
export const merge = S.merge
|
|
||||||
export const disabled = S.disabled
|
|
||||||
|
|
||||||
export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
|
export const AskInput = Request.partial({ id: true }).extend({
|
||||||
|
ruleset: Ruleset,
|
||||||
|
})
|
||||||
|
|
||||||
export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
|
export const ReplyInput = z.object({
|
||||||
|
requestID: PermissionID.zod,
|
||||||
|
reply: Reply,
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||||
|
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
pending: Map<PermissionID, PendingEntry>
|
||||||
|
approved: Ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||||
|
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||||
|
return evalRule(permission, pattern, ...rulesets)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Permission.state")(function* (ctx) {
|
||||||
|
const row = Database.use((db) =>
|
||||||
|
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
|
||||||
|
)
|
||||||
|
const state = {
|
||||||
|
pending: new Map<PermissionID, PendingEntry>(),
|
||||||
|
approved: row?.data ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
for (const item of state.pending.values()) {
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
state.pending.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const { ruleset, ...request } = input
|
||||||
|
let needsAsk = false
|
||||||
|
|
||||||
|
for (const pattern of request.patterns) {
|
||||||
|
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||||
|
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||||
|
if (rule.action === "deny") {
|
||||||
|
return yield* new DeniedError({
|
||||||
|
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rule.action === "allow") continue
|
||||||
|
needsAsk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsAsk) return
|
||||||
|
|
||||||
|
const id = request.id ?? PermissionID.ascending()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
...request,
|
||||||
|
}
|
||||||
|
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
void Bus.publish(Event.Asked, info)
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||||
|
const { approved, pending } = yield* InstanceState.get(state)
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
reply: input.reply,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.reply === "reject") {
|
||||||
|
yield* Deferred.fail(
|
||||||
|
existing.deferred,
|
||||||
|
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "reject",
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Deferred.succeed(existing.deferred, undefined)
|
||||||
|
if (input.reply === "once") return
|
||||||
|
|
||||||
|
for (const pattern of existing.info.always) {
|
||||||
|
approved.push({
|
||||||
|
permission: existing.info.permission,
|
||||||
|
pattern,
|
||||||
|
action: "allow",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, item] of pending.entries()) {
|
||||||
|
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||||
|
const ok = item.info.patterns.every(
|
||||||
|
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||||
|
)
|
||||||
|
if (!ok) continue
|
||||||
|
pending.delete(id)
|
||||||
|
void Bus.publish(Event.Replied, {
|
||||||
|
sessionID: item.info.sessionID,
|
||||||
|
requestID: item.info.id,
|
||||||
|
reply: "always",
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(item.deferred, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Permission.list")(function* () {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
return Array.from(pending.values(), (item) => item.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function expand(pattern: string): string {
|
||||||
|
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||||
|
if (pattern === "~") return os.homedir()
|
||||||
|
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||||
|
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromConfig(permission: Config.Permission) {
|
||||||
|
const ruleset: Ruleset = []
|
||||||
|
for (const [key, value] of Object.entries(permission)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ruleset.push(
|
||||||
|
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ruleset
|
||||||
|
}
|
||||||
|
|
||||||
|
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||||
|
return rulesets.flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||||
|
|
||||||
|
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||||
|
const result = new Set<string>()
|
||||||
|
for (const tool of tools) {
|
||||||
|
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||||
|
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||||
|
if (!rule) continue
|
||||||
|
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
|
export async function ask(input: z.infer<typeof AskInput>) {
|
||||||
|
return runPromise((s) => s.ask(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reply(input: z.infer<typeof ReplyInput>) {
|
||||||
|
return runPromise((s) => s.reply(input))
|
||||||
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.list()))
|
return runPromise((s) => s.list())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { Config } from "@/config/config"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
|
||||||
import { PermissionTable } from "@/session/session.sql"
|
|
||||||
import { Database, eq } from "@/storage/db"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import { Wildcard } from "@/util/wildcard"
|
|
||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import os from "os"
|
|
||||||
import z from "zod"
|
|
||||||
import { evaluate as evalRule } from "./evaluate"
|
|
||||||
import { PermissionID } from "./schema"
|
|
||||||
|
|
||||||
export namespace Permission {
|
|
||||||
const log = Log.create({ service: "permission" })
|
|
||||||
|
|
||||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
|
||||||
ref: "PermissionAction",
|
|
||||||
})
|
|
||||||
export type Action = z.infer<typeof Action>
|
|
||||||
|
|
||||||
export const Rule = z
|
|
||||||
.object({
|
|
||||||
permission: z.string(),
|
|
||||||
pattern: z.string(),
|
|
||||||
action: Action,
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRule",
|
|
||||||
})
|
|
||||||
export type Rule = z.infer<typeof Rule>
|
|
||||||
|
|
||||||
export const Ruleset = Rule.array().meta({
|
|
||||||
ref: "PermissionRuleset",
|
|
||||||
})
|
|
||||||
export type Ruleset = z.infer<typeof Ruleset>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: PermissionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
permission: z.string(),
|
|
||||||
patterns: z.string().array(),
|
|
||||||
metadata: z.record(z.string(), z.any()),
|
|
||||||
always: z.string().array(),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionRequest",
|
|
||||||
})
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Reply = z.enum(["once", "always", "reject"])
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Approval = z.object({
|
|
||||||
projectID: ProjectID.zod,
|
|
||||||
patterns: z.string().array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("permission.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"permission.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user rejected permission to use this specific tool call."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
|
||||||
feedback: Schema.String,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
|
||||||
ruleset: Schema.Any,
|
|
||||||
}) {
|
|
||||||
override get message() {
|
|
||||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Error = DeniedError | RejectedError | CorrectedError
|
|
||||||
|
|
||||||
export const AskInput = Request.partial({ id: true }).extend({
|
|
||||||
ruleset: Ruleset,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ReplyInput = z.object({
|
|
||||||
requestID: PermissionID.zod,
|
|
||||||
reply: Reply,
|
|
||||||
message: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
|
||||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
|
||||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
|
||||||
return evalRule(permission, pattern, ...rulesets)
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { project } = yield* InstanceContext
|
|
||||||
const row = Database.use((db) =>
|
|
||||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
|
||||||
)
|
|
||||||
const pending = new Map<PermissionID, PendingEntry>()
|
|
||||||
const approved: Ruleset = row?.data ?? []
|
|
||||||
|
|
||||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
|
||||||
const { ruleset, ...request } = input
|
|
||||||
let needsAsk = false
|
|
||||||
|
|
||||||
for (const pattern of request.patterns) {
|
|
||||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
|
||||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
|
||||||
if (rule.action === "deny") {
|
|
||||||
return yield* new DeniedError({
|
|
||||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (rule.action === "allow") continue
|
|
||||||
needsAsk = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsAsk) return
|
|
||||||
|
|
||||||
const id = request.id ?? PermissionID.ascending()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
...request,
|
|
||||||
}
|
|
||||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
void Bus.publish(Event.Asked, info)
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return
|
|
||||||
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
reply: input.reply,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.reply === "reject") {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
existing.deferred,
|
|
||||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "reject",
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(existing.deferred, undefined)
|
|
||||||
if (input.reply === "once") return
|
|
||||||
|
|
||||||
for (const pattern of existing.info.always) {
|
|
||||||
approved.push({
|
|
||||||
permission: existing.info.permission,
|
|
||||||
pattern,
|
|
||||||
action: "allow",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, item] of pending.entries()) {
|
|
||||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
|
||||||
const ok = item.info.patterns.every(
|
|
||||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
|
||||||
)
|
|
||||||
if (!ok) continue
|
|
||||||
pending.delete(id)
|
|
||||||
void Bus.publish(Event.Replied, {
|
|
||||||
sessionID: item.info.sessionID,
|
|
||||||
requestID: item.info.id,
|
|
||||||
reply: "always",
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(item.deferred, undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Permission.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (item) => item.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, list })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
|
|
||||||
function expand(pattern: string): string {
|
|
||||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
|
||||||
if (pattern === "~") return os.homedir()
|
|
||||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
|
||||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
|
||||||
return pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromConfig(permission: Config.Permission) {
|
|
||||||
const ruleset: Ruleset = []
|
|
||||||
for (const [key, value] of Object.entries(permission)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ruleset.push(
|
|
||||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ruleset
|
|
||||||
}
|
|
||||||
|
|
||||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
|
||||||
return rulesets.flat()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
|
||||||
|
|
||||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
|
||||||
const result = new Set<string>()
|
|
||||||
for (const tool of tools) {
|
|
||||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
|
||||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
|
||||||
if (!rule) continue
|
|
||||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,140 +5,202 @@ import { Log } from "../util/log"
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { Server } from "../server/server"
|
import { Server } from "../server/server"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { CodexAuthPlugin } from "./codex"
|
import { CodexAuthPlugin } from "./codex"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { CopilotAuthPlugin } from "./copilot"
|
import { CopilotAuthPlugin } from "./copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
hooks: Hooks[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook names that follow the (input, output) => Promise<void> trigger pattern
|
||||||
|
type TriggerName = {
|
||||||
|
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
|
||||||
|
}[keyof Hooks]
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly trigger: <
|
||||||
|
Name extends TriggerName,
|
||||||
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
|
>(
|
||||||
|
name: Name,
|
||||||
|
input: Input,
|
||||||
|
output: Output,
|
||||||
|
) => Effect.Effect<Output>
|
||||||
|
readonly list: () => Effect.Effect<Hooks[]>
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
// Old npm package names for plugins that are now built-in — skip if users still have them in config
|
||||||
const client = createOpencodeClient({
|
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
|
||||||
baseUrl: "http://localhost:4096",
|
|
||||||
directory: Instance.directory,
|
|
||||||
headers: Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
? {
|
|
||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
fetch: async (...args) => Server.Default().fetch(...args),
|
|
||||||
})
|
|
||||||
const config = await Config.get()
|
|
||||||
const hooks: Hooks[] = []
|
|
||||||
const input: PluginInput = {
|
|
||||||
client,
|
|
||||||
project: Instance.project,
|
|
||||||
worktree: Instance.worktree,
|
|
||||||
directory: Instance.directory,
|
|
||||||
get serverUrl(): URL {
|
|
||||||
return Server.url ?? new URL("http://localhost:4096")
|
|
||||||
},
|
|
||||||
$: Bun.$,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const plugin of INTERNAL_PLUGINS) {
|
export const layer = Layer.effect(
|
||||||
log.info("loading internal plugin", { name: plugin.name })
|
Service,
|
||||||
const init = await plugin(input).catch((err) => {
|
Effect.gen(function* () {
|
||||||
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
const cache = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Plugin.state")(function* (ctx) {
|
||||||
|
const hooks: Hooks[] = []
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: "http://localhost:4096",
|
||||||
|
directory: ctx.directory,
|
||||||
|
headers: Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
fetch: async (...args) => Server.Default().fetch(...args),
|
||||||
|
})
|
||||||
|
const cfg = await Config.get()
|
||||||
|
const input: PluginInput = {
|
||||||
|
client,
|
||||||
|
project: ctx.project,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
directory: ctx.directory,
|
||||||
|
get serverUrl(): URL {
|
||||||
|
return Server.url ?? new URL("http://localhost:4096")
|
||||||
|
},
|
||||||
|
$: Bun.$,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of INTERNAL_PLUGINS) {
|
||||||
|
log.info("loading internal plugin", { name: plugin.name })
|
||||||
|
const init = await plugin(input).catch((err) => {
|
||||||
|
log.error("failed to load internal plugin", { name: plugin.name, error: err })
|
||||||
|
})
|
||||||
|
if (init) hooks.push(init)
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugins = cfg.plugin ?? []
|
||||||
|
if (plugins.length) await Config.waitForDependencies()
|
||||||
|
|
||||||
|
for (let plugin of plugins) {
|
||||||
|
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
|
||||||
|
log.info("loading plugin", { path: plugin })
|
||||||
|
if (!plugin.startsWith("file://")) {
|
||||||
|
const idx = plugin.lastIndexOf("@")
|
||||||
|
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
|
||||||
|
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
|
||||||
|
plugin = await BunProc.install(pkg, version).catch((err) => {
|
||||||
|
const cause = err instanceof Error ? err.cause : err
|
||||||
|
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
||||||
|
log.error("failed to install plugin", { pkg, version, error: detail })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if (!plugin) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate initialization when plugins export the same function
|
||||||
|
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
||||||
|
// Object.entries(mod) would return both entries pointing to the same function reference.
|
||||||
|
await import(plugin)
|
||||||
|
.then(async (mod) => {
|
||||||
|
const seen = new Set<PluginInstance>()
|
||||||
|
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
||||||
|
if (seen.has(fn)) continue
|
||||||
|
seen.add(fn)
|
||||||
|
hooks.push(await fn(input))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
log.error("failed to load plugin", { path: plugin, error: message })
|
||||||
|
Bus.publish(Session.Event.Error, {
|
||||||
|
error: new NamedError.Unknown({
|
||||||
|
message: `Failed to load plugin ${plugin}: ${message}`,
|
||||||
|
}).toObject(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify plugins of current config
|
||||||
|
for (const hook of hooks) {
|
||||||
|
await (hook as any).config?.(cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to bus events, clean up when scope is closed
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bus.subscribeAll(async (input) => {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
hook["event"]?.({ event: input })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(unsub) => Effect.sync(unsub),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { hooks }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = Effect.fn("Plugin.trigger")(function* <
|
||||||
|
Name extends TriggerName,
|
||||||
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
|
>(name: Name, input: Input, output: Output) {
|
||||||
|
if (!name) return output
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
for (const hook of state.hooks) {
|
||||||
|
const fn = hook[name] as any
|
||||||
|
if (!fn) continue
|
||||||
|
await fn(input, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return output
|
||||||
})
|
})
|
||||||
if (init) hooks.push(init)
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugins = config.plugin ?? []
|
const list = Effect.fn("Plugin.list")(function* () {
|
||||||
if (plugins.length) await Config.waitForDependencies()
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return state.hooks
|
||||||
|
})
|
||||||
|
|
||||||
for (let plugin of plugins) {
|
const init = Effect.fn("Plugin.init")(function* () {
|
||||||
// ignore old codex plugin since it is supported first party now
|
yield* InstanceState.get(cache)
|
||||||
if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
|
})
|
||||||
log.info("loading plugin", { path: plugin })
|
|
||||||
if (!plugin.startsWith("file://")) {
|
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
|
||||||
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
|
|
||||||
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
|
|
||||||
plugin = await BunProc.install(pkg, version).catch((err) => {
|
|
||||||
const cause = err instanceof Error ? err.cause : err
|
|
||||||
const detail = cause instanceof Error ? cause.message : String(cause ?? err)
|
|
||||||
log.error("failed to install plugin", { pkg, version, error: detail })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
if (!plugin) continue
|
|
||||||
}
|
|
||||||
// Prevent duplicate initialization when plugins export the same function
|
|
||||||
// as both a named export and default export (e.g., `export const X` and `export default X`).
|
|
||||||
// Object.entries(mod) would return both entries pointing to the same function reference.
|
|
||||||
await import(plugin)
|
|
||||||
.then(async (mod) => {
|
|
||||||
const seen = new Set<PluginInstance>()
|
|
||||||
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
|
||||||
if (seen.has(fn)) continue
|
|
||||||
seen.add(fn)
|
|
||||||
hooks.push(await fn(input))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
log.error("failed to load plugin", { path: plugin, error: message })
|
|
||||||
Bus.publish(Session.Event.Error, {
|
|
||||||
error: new NamedError.Unknown({
|
|
||||||
message: `Failed to load plugin ${plugin}: ${message}`,
|
|
||||||
}).toObject(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return Service.of({ trigger, list, init })
|
||||||
hooks,
|
}),
|
||||||
input,
|
)
|
||||||
}
|
|
||||||
})
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export async function trigger<
|
export async function trigger<
|
||||||
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
|
Name extends TriggerName,
|
||||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||||
if (!name) return output
|
return runPromise((svc) => svc.trigger(name, input, output))
|
||||||
for (const hook of await state().then((x) => x.hooks)) {
|
|
||||||
const fn = hook[name]
|
|
||||||
if (!fn) continue
|
|
||||||
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
|
|
||||||
// give up.
|
|
||||||
// try-counter: 2
|
|
||||||
await fn(input, output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list(): Promise<Hooks[]> {
|
||||||
return state().then((x) => x.hooks)
|
return runPromise((svc) => svc.list())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
const hooks = await state().then((x) => x.hooks)
|
return runPromise((svc) => svc.init())
|
||||||
const config = await Config.get()
|
|
||||||
for (const hook of hooks) {
|
|
||||||
// @ts-expect-error this is because we haven't moved plugin to sdk v2
|
|
||||||
await hook.config?.(config)
|
|
||||||
}
|
|
||||||
Bus.subscribeAll(async (input) => {
|
|
||||||
const hooks = await state().then((x) => x.hooks)
|
|
||||||
for (const hook of hooks) {
|
|
||||||
hook["event"]?.({
|
|
||||||
event: input,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
|
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 { Project } from "./project"
|
import { Project } from "./project"
|
||||||
|
import { Vcs } from "./vcs"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
|
|||||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||||
await Plugin.init()
|
await Plugin.init()
|
||||||
ShareNext.init()
|
ShareNext.init()
|
||||||
|
Format.init()
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
File.init()
|
File.init()
|
||||||
|
FileWatcher.init()
|
||||||
|
Vcs.init()
|
||||||
|
Snapshot.init()
|
||||||
|
|
||||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
if (payload.properties.name === Command.Default.INIT) {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
|
|||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
interface Context {
|
export interface Shape {
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
project: Project.Info
|
project: Project.Info
|
||||||
}
|
}
|
||||||
const context = Context.create<Context>("instance")
|
const context = Context.create<Shape>("instance")
|
||||||
const cache = new Map<string, Promise<Context>>()
|
const cache = new Map<string, Promise<Shape>>()
|
||||||
|
|
||||||
const disposal = {
|
const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
all: undefined as Promise<void> | undefined,
|
||||||
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function track(directory: string, next: Promise<Context>) {
|
function track(directory: string, next: Promise<Shape>) {
|
||||||
const task = next.catch((error) => {
|
const task = next.catch((error) => {
|
||||||
if (cache.get(directory) === task) cache.delete(directory)
|
if (cache.get(directory) === task) cache.delete(directory)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
@@ -30,54 +31,81 @@ export namespace Vcs {
|
|||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
readonly branch: () => Effect.Effect<string | undefined>
|
readonly branch: () => Effect.Effect<string | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
current: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const instance = yield* InstanceContext
|
const state = yield* InstanceState.make<State>(
|
||||||
let currentBranch: string | undefined
|
Effect.fn("Vcs.state")((ctx) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
|
return { current: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
if (instance.project.vcs === "git") {
|
const getCurrentBranch = async () => {
|
||||||
const getCurrentBranch = async () => {
|
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
cwd: ctx.worktree,
|
||||||
cwd: instance.project.worktree,
|
})
|
||||||
})
|
if (result.exitCode !== 0) return undefined
|
||||||
if (result.exitCode !== 0) return undefined
|
const text = result.text().trim()
|
||||||
const text = result.text().trim()
|
return text || undefined
|
||||||
return text || undefined
|
}
|
||||||
}
|
|
||||||
|
|
||||||
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
const value = {
|
||||||
log.info("initialized", { branch: currentBranch })
|
current: yield* Effect.promise(() => getCurrentBranch()),
|
||||||
|
}
|
||||||
|
log.info("initialized", { branch: value.current })
|
||||||
|
|
||||||
yield* Effect.acquireRelease(
|
yield* Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bus.subscribe(
|
Bus.subscribe(
|
||||||
FileWatcher.Event.Updated,
|
FileWatcher.Event.Updated,
|
||||||
Instance.bind(async (evt) => {
|
Instance.bind(async (evt) => {
|
||||||
if (!evt.properties.file.endsWith("HEAD")) return
|
if (!evt.properties.file.endsWith("HEAD")) return
|
||||||
const next = await getCurrentBranch()
|
const next = await getCurrentBranch()
|
||||||
if (next !== currentBranch) {
|
if (next !== value.current) {
|
||||||
log.info("branch changed", { from: currentBranch, to: next })
|
log.info("branch changed", { from: value.current, to: next })
|
||||||
currentBranch = next
|
value.current = next
|
||||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(unsubscribe) => Effect.sync(unsubscribe),
|
(unsubscribe) => Effect.sync(unsubscribe),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
return value
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
init: Effect.fn("Vcs.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
branch: Effect.fn("Vcs.branch")(function* () {
|
branch: Effect.fn("Vcs.branch")(function* () {
|
||||||
return currentBranch
|
return yield* InstanceState.use(state, (x) => x.current)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.fresh)
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
return runPromise((svc) => svc.init())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branch() {
|
||||||
|
return runPromise((svc) => svc.branch())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import * as Auth from "@/auth/effect"
|
|
||||||
import { ProviderID } from "./schema"
|
|
||||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
|
||||||
import z from "zod"
|
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
|
||||||
export const Method = z
|
|
||||||
.object({
|
|
||||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
|
||||||
label: z.string(),
|
|
||||||
prompts: z
|
|
||||||
.array(
|
|
||||||
z.union([
|
|
||||||
z.object({
|
|
||||||
type: z.literal("text"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
placeholder: z.string().optional(),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
type: z.literal("select"),
|
|
||||||
key: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
options: z.array(
|
|
||||||
z.object({
|
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
hint: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
when: z
|
|
||||||
.object({
|
|
||||||
key: z.string(),
|
|
||||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
|
||||||
value: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthMethod",
|
|
||||||
})
|
|
||||||
export type Method = z.infer<typeof Method>
|
|
||||||
|
|
||||||
export const Authorization = z
|
|
||||||
.object({
|
|
||||||
url: z.string(),
|
|
||||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
|
||||||
instructions: z.string(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "ProviderAuthAuthorization",
|
|
||||||
})
|
|
||||||
export type Authorization = z.infer<typeof Authorization>
|
|
||||||
|
|
||||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
|
||||||
|
|
||||||
export const OauthCodeMissing = NamedError.create(
|
|
||||||
"ProviderAuthOauthCodeMissing",
|
|
||||||
z.object({ providerID: ProviderID.zod }),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
|
||||||
|
|
||||||
export const ValidationFailed = NamedError.create(
|
|
||||||
"ProviderAuthValidationFailed",
|
|
||||||
z.object({
|
|
||||||
field: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type Error =
|
|
||||||
| Auth.AuthError
|
|
||||||
| InstanceType<typeof OauthMissing>
|
|
||||||
| InstanceType<typeof OauthCodeMissing>
|
|
||||||
| InstanceType<typeof OauthCallbackFailed>
|
|
||||||
| InstanceType<typeof ValidationFailed>
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
|
||||||
readonly authorize: (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) => Effect.Effect<Authorization | undefined, Error>
|
|
||||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* Auth.Auth.Service
|
|
||||||
const hooks = yield* Effect.promise(async () => {
|
|
||||||
const mod = await import("../plugin")
|
|
||||||
const plugins = await mod.Plugin.list()
|
|
||||||
return Record.fromEntries(
|
|
||||||
Arr.filterMap(plugins, (x) =>
|
|
||||||
x.auth?.provider !== undefined
|
|
||||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
|
||||||
: Result.failVoid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
|
||||||
|
|
||||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
|
||||||
return Record.map(hooks, (item) =>
|
|
||||||
item.methods.map(
|
|
||||||
(method): Method => ({
|
|
||||||
type: method.type,
|
|
||||||
label: method.label,
|
|
||||||
prompts: method.prompts?.map((prompt) => {
|
|
||||||
if (prompt.type === "select") {
|
|
||||||
return {
|
|
||||||
type: "select" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
options: prompt.options,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "text" as const,
|
|
||||||
key: prompt.key,
|
|
||||||
message: prompt.message,
|
|
||||||
placeholder: prompt.placeholder,
|
|
||||||
when: prompt.when,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
inputs?: Record<string, string>
|
|
||||||
}) {
|
|
||||||
const method = hooks[input.providerID].methods[input.method]
|
|
||||||
if (method.type !== "oauth") return
|
|
||||||
|
|
||||||
if (method.prompts && input.inputs) {
|
|
||||||
for (const prompt of method.prompts) {
|
|
||||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
|
||||||
const error = prompt.validate(input.inputs[prompt.key])
|
|
||||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
|
||||||
pending.set(input.providerID, result)
|
|
||||||
return {
|
|
||||||
url: result.url,
|
|
||||||
method: result.method,
|
|
||||||
instructions: result.instructions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
|
||||||
providerID: ProviderID
|
|
||||||
method: number
|
|
||||||
code?: string
|
|
||||||
}) {
|
|
||||||
const match = pending.get(input.providerID)
|
|
||||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
|
||||||
if (match.method === "code" && !input.code) {
|
|
||||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = yield* Effect.promise(() =>
|
|
||||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
|
||||||
)
|
|
||||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
|
||||||
|
|
||||||
if ("key" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "api",
|
|
||||||
key: result.key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("refresh" in result) {
|
|
||||||
yield* auth.set(input.providerID, {
|
|
||||||
type: "oauth",
|
|
||||||
access: result.access,
|
|
||||||
refresh: result.refresh,
|
|
||||||
expires: result.expires,
|
|
||||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ methods, authorize, callback })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
|
|
||||||
}
|
|
||||||
@@ -1,48 +1,251 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||||
import { fn } from "@/util/fn"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { Plugin } from "../plugin"
|
||||||
import { ProviderID } from "./schema"
|
import { ProviderID } from "./schema"
|
||||||
|
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ProviderAuth as S } from "./auth-service"
|
|
||||||
|
|
||||||
export namespace ProviderAuth {
|
export namespace ProviderAuth {
|
||||||
export const Method = S.Method
|
export const Method = z
|
||||||
export type Method = S.Method
|
.object({
|
||||||
|
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||||
|
label: z.string(),
|
||||||
|
prompts: z
|
||||||
|
.array(
|
||||||
|
z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
placeholder: z.string().optional(),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("select"),
|
||||||
|
key: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
options: z.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
hint: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
when: z
|
||||||
|
.object({
|
||||||
|
key: z.string(),
|
||||||
|
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthMethod",
|
||||||
|
})
|
||||||
|
export type Method = z.infer<typeof Method>
|
||||||
|
|
||||||
export const Authorization = S.Authorization
|
export const Authorization = z
|
||||||
export type Authorization = S.Authorization
|
.object({
|
||||||
|
url: z.string(),
|
||||||
|
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||||
|
instructions: z.string(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "ProviderAuthAuthorization",
|
||||||
|
})
|
||||||
|
export type Authorization = z.infer<typeof Authorization>
|
||||||
|
|
||||||
export const OauthMissing = S.OauthMissing
|
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||||
export const OauthCodeMissing = S.OauthCodeMissing
|
|
||||||
export const OauthCallbackFailed = S.OauthCallbackFailed
|
|
||||||
export const ValidationFailed = S.ValidationFailed
|
|
||||||
export type Error = S.Error
|
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export const OauthCodeMissing = NamedError.create(
|
||||||
|
"ProviderAuthOauthCodeMissing",
|
||||||
|
z.object({ providerID: ProviderID.zod }),
|
||||||
|
)
|
||||||
|
|
||||||
export const Service = S.Service
|
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||||
export const layer = S.layer
|
|
||||||
export const defaultLayer = S.defaultLayer
|
|
||||||
|
|
||||||
export async function methods() {
|
export const ValidationFailed = NamedError.create(
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.methods()))
|
"ProviderAuthValidationFailed",
|
||||||
|
z.object({
|
||||||
|
field: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type Error =
|
||||||
|
| Auth.AuthError
|
||||||
|
| InstanceType<typeof OauthMissing>
|
||||||
|
| InstanceType<typeof OauthCodeMissing>
|
||||||
|
| InstanceType<typeof OauthCallbackFailed>
|
||||||
|
| InstanceType<typeof ValidationFailed>
|
||||||
|
|
||||||
|
type Hook = NonNullable<Hooks["auth"]>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||||
|
readonly authorize: (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) => Effect.Effect<Authorization | undefined, Error>
|
||||||
|
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authorize = fn(
|
interface State {
|
||||||
z.object({
|
hooks: Record<ProviderID, Hook>
|
||||||
providerID: ProviderID.zod,
|
pending: Map<ProviderID, AuthOuathResult>
|
||||||
method: z.number(),
|
}
|
||||||
inputs: z.record(z.string(), z.string()).optional(),
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("ProviderAuth.state")(() =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
const plugins = await Plugin.list()
|
||||||
|
return {
|
||||||
|
hooks: Record.fromEntries(
|
||||||
|
Arr.filterMap(plugins, (x) =>
|
||||||
|
x.auth?.provider !== undefined
|
||||||
|
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||||
|
: Result.failVoid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pending: new Map<ProviderID, AuthOuathResult>(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||||
|
const hooks = (yield* InstanceState.get(state)).hooks
|
||||||
|
return Record.map(hooks, (item) =>
|
||||||
|
item.methods.map(
|
||||||
|
(method): Method => ({
|
||||||
|
type: method.type,
|
||||||
|
label: method.label,
|
||||||
|
prompts: method.prompts?.map((prompt) => {
|
||||||
|
if (prompt.type === "select") {
|
||||||
|
return {
|
||||||
|
type: "select" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
options: prompt.options,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "text" as const,
|
||||||
|
key: prompt.key,
|
||||||
|
message: prompt.message,
|
||||||
|
placeholder: prompt.placeholder,
|
||||||
|
when: prompt.when,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}) {
|
||||||
|
const { hooks, pending } = yield* InstanceState.get(state)
|
||||||
|
const method = hooks[input.providerID].methods[input.method]
|
||||||
|
if (method.type !== "oauth") return
|
||||||
|
|
||||||
|
if (method.prompts && input.inputs) {
|
||||||
|
for (const prompt of method.prompts) {
|
||||||
|
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||||
|
const error = prompt.validate(input.inputs[prompt.key])
|
||||||
|
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||||
|
pending.set(input.providerID, result)
|
||||||
|
return {
|
||||||
|
url: result.url,
|
||||||
|
method: result.method,
|
||||||
|
instructions: result.instructions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
code?: string
|
||||||
|
}) {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
const match = pending.get(input.providerID)
|
||||||
|
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||||
|
if (match.method === "code" && !input.code) {
|
||||||
|
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||||
|
)
|
||||||
|
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||||
|
|
||||||
|
if ("key" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "api",
|
||||||
|
key: result.key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("refresh" in result) {
|
||||||
|
yield* auth.set(input.providerID, {
|
||||||
|
type: "oauth",
|
||||||
|
access: result.access,
|
||||||
|
refresh: result.refresh,
|
||||||
|
expires: result.expires,
|
||||||
|
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ methods, authorize, callback })
|
||||||
}),
|
}),
|
||||||
async (input): Promise<Authorization | undefined> =>
|
|
||||||
runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const callback = fn(
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
method: z.number(),
|
|
||||||
code: z.string().optional(),
|
export async function methods() {
|
||||||
}),
|
return runPromise((svc) => svc.methods())
|
||||||
async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
|
}
|
||||||
)
|
|
||||||
|
export async function authorize(input: {
|
||||||
|
providerID: ProviderID
|
||||||
|
method: number
|
||||||
|
inputs?: Record<string, string>
|
||||||
|
}): Promise<Authorization | undefined> {
|
||||||
|
return runPromise((svc) => svc.authorize(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
|
||||||
|
return runPromise((svc) => svc.callback(input))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -755,11 +755,13 @@ export namespace ProviderTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
|
||||||
result["thinkingConfig"] = {
|
if (input.model.capabilities.reasoning) {
|
||||||
includeThoughts: true,
|
result["thinkingConfig"] = {
|
||||||
}
|
includeThoughts: true,
|
||||||
if (input.model.api.id.includes("gemini-3")) {
|
}
|
||||||
result["thinkingConfig"]["thinkingLevel"] = "high"
|
if (input.model.api.id.includes("gemini-3")) {
|
||||||
|
result["thinkingConfig"]["thinkingLevel"] = "high"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+289
-213
@@ -1,13 +1,16 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
import { type IPty } from "bun-pty"
|
import { type IPty } from "bun-pty"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { lazy } from "@opencode-ai/util/lazy"
|
import { lazy } from "@opencode-ai/util/lazy"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { PtyID } from "./schema"
|
import { PtyID } from "./schema"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
|
||||||
export namespace Pty {
|
export namespace Pty {
|
||||||
const log = Log.create({ service: "pty" })
|
const log = Log.create({ service: "pty" })
|
||||||
@@ -23,6 +26,20 @@ export namespace Pty {
|
|||||||
close: (code?: number, reason?: string) => void
|
close: (code?: number, reason?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Active = {
|
||||||
|
info: Info
|
||||||
|
process: IPty
|
||||||
|
buffer: string
|
||||||
|
bufferCursor: number
|
||||||
|
cursor: number
|
||||||
|
subscribers: Map<unknown, Socket>
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
dir: string
|
||||||
|
sessions: Map<PtyID, Active>
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||||
const meta = (cursor: number) => {
|
const meta = (cursor: number) => {
|
||||||
const json = JSON.stringify({ cursor })
|
const json = JSON.stringify({ cursor })
|
||||||
@@ -81,241 +98,300 @@ export namespace Pty {
|
|||||||
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
|
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveSession {
|
export interface Interface {
|
||||||
info: Info
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
process: IPty
|
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
|
||||||
buffer: string
|
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||||
bufferCursor: number
|
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
|
||||||
cursor: number
|
readonly remove: (id: PtyID) => Effect.Effect<void>
|
||||||
subscribers: Map<unknown, Socket>
|
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
|
||||||
|
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
|
||||||
|
readonly connect: (
|
||||||
|
id: PtyID,
|
||||||
|
ws: Socket,
|
||||||
|
cursor?: number,
|
||||||
|
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Instance.state(
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
|
||||||
() => new Map<PtyID, ActiveSession>(),
|
|
||||||
async (sessions) => {
|
export const layer = Layer.effect(
|
||||||
for (const session of sessions.values()) {
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
function teardown(session: Active) {
|
||||||
try {
|
try {
|
||||||
session.process.kill()
|
session.process.kill()
|
||||||
} catch {}
|
} catch {}
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
try {
|
try {
|
||||||
if (ws.data === key) ws.close()
|
if (ws.data === key) ws.close()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
session.subscribers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Pty.state")(function* (ctx) {
|
||||||
|
const state = {
|
||||||
|
dir: ctx.directory,
|
||||||
|
sessions: new Map<PtyID, Active>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
for (const session of state.sessions.values()) {
|
||||||
|
teardown(session)
|
||||||
|
}
|
||||||
|
state.sessions.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const session = state.sessions.get(id)
|
||||||
|
if (!session) return
|
||||||
|
state.sessions.delete(id)
|
||||||
|
log.info("removing session", { id })
|
||||||
|
teardown(session)
|
||||||
|
void Bus.publish(Event.Deleted, { id: session.info.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Pty.list")(function* () {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return Array.from(state.sessions.values()).map((session) => session.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return state.sessions.get(id)?.info
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
|
const id = PtyID.ascending()
|
||||||
|
const command = input.command || Shell.preferred()
|
||||||
|
const args = input.args || []
|
||||||
|
if (command.endsWith("sh")) {
|
||||||
|
args.push("-l")
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = input.cwd || state.dir
|
||||||
|
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...input.env,
|
||||||
|
...shellEnv.env,
|
||||||
|
TERM: "xterm-256color",
|
||||||
|
OPENCODE_TERMINAL: "1",
|
||||||
|
} as Record<string, string>
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
env.LC_ALL = "C.UTF-8"
|
||||||
|
env.LC_CTYPE = "C.UTF-8"
|
||||||
|
env.LANG = "C.UTF-8"
|
||||||
|
}
|
||||||
|
log.info("creating session", { id, cmd: command, args, cwd })
|
||||||
|
|
||||||
|
const spawn = await pty()
|
||||||
|
const proc = spawn(command, args, {
|
||||||
|
name: "xterm-256color",
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
id,
|
||||||
|
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
cwd,
|
||||||
|
status: "running",
|
||||||
|
pid: proc.pid,
|
||||||
|
} as const
|
||||||
|
const session: Active = {
|
||||||
|
info,
|
||||||
|
process: proc,
|
||||||
|
buffer: "",
|
||||||
|
bufferCursor: 0,
|
||||||
|
cursor: 0,
|
||||||
|
subscribers: new Map(),
|
||||||
|
}
|
||||||
|
state.sessions.set(id, session)
|
||||||
|
proc.onData(
|
||||||
|
Instance.bind((chunk) => {
|
||||||
|
session.cursor += chunk.length
|
||||||
|
|
||||||
|
for (const [key, ws] of session.subscribers.entries()) {
|
||||||
|
if (ws.readyState !== 1) {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ws.data !== key) {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(chunk)
|
||||||
|
} catch {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.buffer += chunk
|
||||||
|
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||||
|
const excess = session.buffer.length - BUFFER_LIMIT
|
||||||
|
session.buffer = session.buffer.slice(excess)
|
||||||
|
session.bufferCursor += excess
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
proc.onExit(
|
||||||
|
Instance.bind(({ exitCode }) => {
|
||||||
|
if (session.info.status === "exited") return
|
||||||
|
log.info("session exited", { id, exitCode })
|
||||||
|
session.info.status = "exited"
|
||||||
|
void Bus.publish(Event.Exited, { id, exitCode })
|
||||||
|
Effect.runFork(remove(id))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Bus.publish(Event.Created, { info })
|
||||||
|
return info
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const session = state.sessions.get(id)
|
||||||
|
if (!session) return
|
||||||
|
if (input.title) {
|
||||||
|
session.info.title = input.title
|
||||||
|
}
|
||||||
|
if (input.size) {
|
||||||
|
session.process.resize(input.size.cols, input.size.rows)
|
||||||
|
}
|
||||||
|
yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
|
||||||
|
return session.info
|
||||||
|
})
|
||||||
|
|
||||||
|
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const session = state.sessions.get(id)
|
||||||
|
if (session && session.info.status === "running") {
|
||||||
|
session.process.resize(cols, rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const session = state.sessions.get(id)
|
||||||
|
if (session && session.info.status === "running") {
|
||||||
|
session.process.write(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const session = state.sessions.get(id)
|
||||||
|
if (!session) {
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("client connected to session", { id })
|
||||||
|
|
||||||
|
// Use ws.data as the unique key for this connection lifecycle.
|
||||||
|
// If ws.data is undefined, fallback to ws object.
|
||||||
|
const key = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||||
|
// Optionally cleanup if the key somehow exists
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
session.subscribers.set(key, ws)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
session.subscribers.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = session.bufferCursor
|
||||||
|
const end = session.cursor
|
||||||
|
const from =
|
||||||
|
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||||
|
|
||||||
|
const data = (() => {
|
||||||
|
if (!session.buffer) return ""
|
||||||
|
if (from >= end) return ""
|
||||||
|
const offset = Math.max(0, from - start)
|
||||||
|
if (offset >= session.buffer.length) return ""
|
||||||
|
return session.buffer.slice(offset)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
||||||
|
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
cleanup()
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
sessions.clear()
|
try {
|
||||||
},
|
ws.send(meta(end))
|
||||||
|
} catch {
|
||||||
|
cleanup()
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onMessage: (message: string | ArrayBuffer) => {
|
||||||
|
session.process.write(String(message))
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
log.info("client disconnected from session", { id })
|
||||||
|
cleanup()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function list() {
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
return Array.from(state().values()).map((s) => s.info)
|
|
||||||
|
export async function list() {
|
||||||
|
return runPromise((svc) => svc.list())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(id: PtyID) {
|
export async function get(id: PtyID) {
|
||||||
return state().get(id)?.info
|
return runPromise((svc) => svc.get(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resize(id: PtyID, cols: number, rows: number) {
|
||||||
|
return runPromise((svc) => svc.resize(id, cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function write(id: PtyID, data: string) {
|
||||||
|
return runPromise((svc) => svc.write(id, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
|
||||||
|
return runPromise((svc) => svc.connect(id, ws, cursor))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(input: CreateInput) {
|
export async function create(input: CreateInput) {
|
||||||
const id = PtyID.ascending()
|
return runPromise((svc) => svc.create(input))
|
||||||
const command = input.command || Shell.preferred()
|
|
||||||
const args = input.args || []
|
|
||||||
if (command.endsWith("sh")) {
|
|
||||||
args.push("-l")
|
|
||||||
}
|
|
||||||
|
|
||||||
const cwd = input.cwd || Instance.directory
|
|
||||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
...input.env,
|
|
||||||
...shellEnv.env,
|
|
||||||
TERM: "xterm-256color",
|
|
||||||
OPENCODE_TERMINAL: "1",
|
|
||||||
} as Record<string, string>
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
env.LC_ALL = "C.UTF-8"
|
|
||||||
env.LC_CTYPE = "C.UTF-8"
|
|
||||||
env.LANG = "C.UTF-8"
|
|
||||||
}
|
|
||||||
log.info("creating session", { id, cmd: command, args, cwd })
|
|
||||||
|
|
||||||
const spawn = await pty()
|
|
||||||
const ptyProcess = spawn(command, args, {
|
|
||||||
name: "xterm-256color",
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
id,
|
|
||||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
cwd,
|
|
||||||
status: "running",
|
|
||||||
pid: ptyProcess.pid,
|
|
||||||
} as const
|
|
||||||
const session: ActiveSession = {
|
|
||||||
info,
|
|
||||||
process: ptyProcess,
|
|
||||||
buffer: "",
|
|
||||||
bufferCursor: 0,
|
|
||||||
cursor: 0,
|
|
||||||
subscribers: new Map(),
|
|
||||||
}
|
|
||||||
state().set(id, session)
|
|
||||||
ptyProcess.onData(
|
|
||||||
Instance.bind((chunk) => {
|
|
||||||
session.cursor += chunk.length
|
|
||||||
|
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
|
||||||
if (ws.readyState !== 1) {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ws.data !== key) {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws.send(chunk)
|
|
||||||
} catch {
|
|
||||||
session.subscribers.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session.buffer += chunk
|
|
||||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
|
||||||
const excess = session.buffer.length - BUFFER_LIMIT
|
|
||||||
session.buffer = session.buffer.slice(excess)
|
|
||||||
session.bufferCursor += excess
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
ptyProcess.onExit(
|
|
||||||
Instance.bind(({ exitCode }) => {
|
|
||||||
if (session.info.status === "exited") return
|
|
||||||
log.info("session exited", { id, exitCode })
|
|
||||||
session.info.status = "exited"
|
|
||||||
Bus.publish(Event.Exited, { id, exitCode })
|
|
||||||
remove(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
Bus.publish(Event.Created, { info })
|
|
||||||
return info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(id: PtyID, input: UpdateInput) {
|
export async function update(id: PtyID, input: UpdateInput) {
|
||||||
const session = state().get(id)
|
return runPromise((svc) => svc.update(id, input))
|
||||||
if (!session) return
|
|
||||||
if (input.title) {
|
|
||||||
session.info.title = input.title
|
|
||||||
}
|
|
||||||
if (input.size) {
|
|
||||||
session.process.resize(input.size.cols, input.size.rows)
|
|
||||||
}
|
|
||||||
Bus.publish(Event.Updated, { info: session.info })
|
|
||||||
return session.info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(id: PtyID) {
|
export async function remove(id: PtyID) {
|
||||||
const session = state().get(id)
|
return runPromise((svc) => svc.remove(id))
|
||||||
if (!session) return
|
|
||||||
state().delete(id)
|
|
||||||
log.info("removing session", { id })
|
|
||||||
try {
|
|
||||||
session.process.kill()
|
|
||||||
} catch {}
|
|
||||||
for (const [key, ws] of session.subscribers.entries()) {
|
|
||||||
try {
|
|
||||||
if (ws.data === key) ws.close()
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
session.subscribers.clear()
|
|
||||||
Bus.publish(Event.Deleted, { id: session.info.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resize(id: PtyID, cols: number, rows: number) {
|
|
||||||
const session = state().get(id)
|
|
||||||
if (session && session.info.status === "running") {
|
|
||||||
session.process.resize(cols, rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function write(id: PtyID, data: string) {
|
|
||||||
const session = state().get(id)
|
|
||||||
if (session && session.info.status === "running") {
|
|
||||||
session.process.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function connect(id: PtyID, ws: Socket, cursor?: number) {
|
|
||||||
const session = state().get(id)
|
|
||||||
if (!session) {
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("client connected to session", { id })
|
|
||||||
|
|
||||||
// Use ws.data as the unique key for this connection lifecycle.
|
|
||||||
// If ws.data is undefined, fallback to ws object.
|
|
||||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
|
||||||
|
|
||||||
// Optionally cleanup if the key somehow exists
|
|
||||||
session.subscribers.delete(connectionKey)
|
|
||||||
session.subscribers.set(connectionKey, ws)
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
session.subscribers.delete(connectionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = session.bufferCursor
|
|
||||||
const end = session.cursor
|
|
||||||
|
|
||||||
const from =
|
|
||||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
|
||||||
|
|
||||||
const data = (() => {
|
|
||||||
if (!session.buffer) return ""
|
|
||||||
if (from >= end) return ""
|
|
||||||
const offset = Math.max(0, from - start)
|
|
||||||
if (offset >= session.buffer.length) return ""
|
|
||||||
return session.buffer.slice(offset)
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
|
|
||||||
ws.send(data.slice(i, i + BUFFER_CHUNK))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
cleanup()
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws.send(meta(end))
|
|
||||||
} catch {
|
|
||||||
cleanup()
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
onMessage: (message: string | ArrayBuffer) => {
|
|
||||||
session.process.write(String(message))
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
log.info("client disconnected from session", { id })
|
|
||||||
cleanup()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,221 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||||
import type { MessageID, SessionID } from "@/session/schema"
|
import { Bus } from "@/bus"
|
||||||
import type { QuestionID } from "./schema"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Question as S } from "./service"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import z from "zod"
|
||||||
|
import { QuestionID } from "./schema"
|
||||||
|
|
||||||
export namespace Question {
|
export namespace Question {
|
||||||
export const Option = S.Option
|
const log = Log.create({ service: "question" })
|
||||||
export type Option = S.Option
|
|
||||||
|
|
||||||
export const Info = S.Info
|
// Schemas
|
||||||
export type Info = S.Info
|
|
||||||
|
|
||||||
export const Request = S.Request
|
export const Option = z
|
||||||
export type Request = S.Request
|
.object({
|
||||||
|
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||||
|
description: z.string().describe("Explanation of choice"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionOption" })
|
||||||
|
export type Option = z.infer<typeof Option>
|
||||||
|
|
||||||
export const Answer = S.Answer
|
export const Info = z
|
||||||
export type Answer = S.Answer
|
.object({
|
||||||
|
question: z.string().describe("Complete question"),
|
||||||
|
header: z.string().describe("Very short label (max 30 chars)"),
|
||||||
|
options: z.array(Option).describe("Available choices"),
|
||||||
|
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||||
|
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionInfo" })
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export const Reply = S.Reply
|
export const Request = z
|
||||||
export type Reply = S.Reply
|
.object({
|
||||||
|
id: QuestionID.zod,
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
questions: z.array(Info).describe("Questions to ask"),
|
||||||
|
tool: z
|
||||||
|
.object({
|
||||||
|
messageID: MessageID.zod,
|
||||||
|
callID: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.meta({ ref: "QuestionRequest" })
|
||||||
|
export type Request = z.infer<typeof Request>
|
||||||
|
|
||||||
export const Event = S.Event
|
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||||
export const RejectedError = S.RejectedError
|
export type Answer = z.infer<typeof Answer>
|
||||||
|
|
||||||
export type Interface = S.Interface
|
export const Reply = z.object({
|
||||||
|
answers: z
|
||||||
|
.array(Answer)
|
||||||
|
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||||
|
})
|
||||||
|
export type Reply = z.infer<typeof Reply>
|
||||||
|
|
||||||
export const Service = S.Service
|
export const Event = {
|
||||||
export const layer = S.layer
|
Asked: BusEvent.define("question.asked", Request),
|
||||||
|
Replied: BusEvent.define(
|
||||||
|
"question.replied",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
answers: z.array(Answer),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Rejected: BusEvent.define(
|
||||||
|
"question.rejected",
|
||||||
|
z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
requestID: QuestionID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "The user dismissed this question"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingEntry {
|
||||||
|
info: Request
|
||||||
|
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
pending: Map<QuestionID, PendingEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly ask: (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) => Effect.Effect<Answer[], RejectedError>
|
||||||
|
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||||
|
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||||
|
readonly list: () => Effect.Effect<Request[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Question.state")(function* () {
|
||||||
|
const state = {
|
||||||
|
pending: new Map<QuestionID, PendingEntry>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
for (const item of state.pending.values()) {
|
||||||
|
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||||
|
}
|
||||||
|
state.pending.clear()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return state
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||||
|
sessionID: SessionID
|
||||||
|
questions: Info[]
|
||||||
|
tool?: { messageID: MessageID; callID: string }
|
||||||
|
}) {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
const id = QuestionID.ascending()
|
||||||
|
log.info("asking", { id, questions: input.questions.length })
|
||||||
|
|
||||||
|
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||||
|
const info: Request = {
|
||||||
|
id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: input.questions,
|
||||||
|
tool: input.tool,
|
||||||
|
}
|
||||||
|
pending.set(id, { info, deferred })
|
||||||
|
Bus.publish(Event.Asked, info)
|
||||||
|
|
||||||
|
return yield* Effect.ensuring(
|
||||||
|
Deferred.await(deferred),
|
||||||
|
Effect.sync(() => {
|
||||||
|
pending.delete(id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
const existing = pending.get(input.requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(input.requestID)
|
||||||
|
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||||
|
Bus.publish(Event.Replied, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
answers: input.answers,
|
||||||
|
})
|
||||||
|
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
const existing = pending.get(requestID)
|
||||||
|
if (!existing) {
|
||||||
|
log.warn("reject for unknown request", { requestID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.delete(requestID)
|
||||||
|
log.info("rejected", { requestID })
|
||||||
|
Bus.publish(Event.Rejected, {
|
||||||
|
sessionID: existing.info.sessionID,
|
||||||
|
requestID: existing.info.id,
|
||||||
|
})
|
||||||
|
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = Effect.fn("Question.list")(function* () {
|
||||||
|
const pending = (yield* InstanceState.get(state)).pending
|
||||||
|
return Array.from(pending.values(), (x) => x.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ask, reply, reject, list })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export async function ask(input: {
|
export async function ask(input: {
|
||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
questions: Info[]
|
questions: Info[]
|
||||||
tool?: { messageID: MessageID; callID: string }
|
tool?: { messageID: MessageID; callID: string }
|
||||||
}): Promise<Answer[]> {
|
}): Promise<Answer[]> {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.ask(input)))
|
return runPromise((s) => s.ask(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.reply(input)))
|
return runPromise((s) => s.reply(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID) {
|
export async function reject(requestID: QuestionID) {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
|
return runPromise((s) => s.reject(requestID))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list() {
|
export async function list() {
|
||||||
return runPromiseInstance(S.Service.use((s) => s.list()))
|
return runPromise((s) => s.list())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
|
||||||
import { Log } from "@/util/log"
|
|
||||||
import z from "zod"
|
|
||||||
import { QuestionID } from "./schema"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "question" })
|
|
||||||
|
|
||||||
export namespace Question {
|
|
||||||
// Schemas
|
|
||||||
|
|
||||||
export const Option = z
|
|
||||||
.object({
|
|
||||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
|
||||||
description: z.string().describe("Explanation of choice"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionOption" })
|
|
||||||
export type Option = z.infer<typeof Option>
|
|
||||||
|
|
||||||
export const Info = z
|
|
||||||
.object({
|
|
||||||
question: z.string().describe("Complete question"),
|
|
||||||
header: z.string().describe("Very short label (max 30 chars)"),
|
|
||||||
options: z.array(Option).describe("Available choices"),
|
|
||||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
|
||||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionInfo" })
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const Request = z
|
|
||||||
.object({
|
|
||||||
id: QuestionID.zod,
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
questions: z.array(Info).describe("Questions to ask"),
|
|
||||||
tool: z
|
|
||||||
.object({
|
|
||||||
messageID: MessageID.zod,
|
|
||||||
callID: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.meta({ ref: "QuestionRequest" })
|
|
||||||
export type Request = z.infer<typeof Request>
|
|
||||||
|
|
||||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
|
||||||
export type Answer = z.infer<typeof Answer>
|
|
||||||
|
|
||||||
export const Reply = z.object({
|
|
||||||
answers: z
|
|
||||||
.array(Answer)
|
|
||||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
|
||||||
})
|
|
||||||
export type Reply = z.infer<typeof Reply>
|
|
||||||
|
|
||||||
export const Event = {
|
|
||||||
Asked: BusEvent.define("question.asked", Request),
|
|
||||||
Replied: BusEvent.define(
|
|
||||||
"question.replied",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
answers: z.array(Answer),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Rejected: BusEvent.define(
|
|
||||||
"question.rejected",
|
|
||||||
z.object({
|
|
||||||
sessionID: SessionID.zod,
|
|
||||||
requestID: QuestionID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user dismissed this question"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingEntry {
|
|
||||||
info: Request
|
|
||||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) => Effect.Effect<Answer[], RejectedError>
|
|
||||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
|
||||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
|
||||||
readonly list: () => Effect.Effect<Request[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const pending = new Map<QuestionID, PendingEntry>()
|
|
||||||
|
|
||||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}) {
|
|
||||||
const id = QuestionID.ascending()
|
|
||||||
log.info("asking", { id, questions: input.questions.length })
|
|
||||||
|
|
||||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
|
||||||
const info: Request = {
|
|
||||||
id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
questions: input.questions,
|
|
||||||
tool: input.tool,
|
|
||||||
}
|
|
||||||
pending.set(id, { info, deferred })
|
|
||||||
Bus.publish(Event.Asked, info)
|
|
||||||
|
|
||||||
return yield* Effect.ensuring(
|
|
||||||
Deferred.await(deferred),
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
|
||||||
Bus.publish(Event.Replied, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
answers: input.answers,
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
|
||||||
const existing = pending.get(requestID)
|
|
||||||
if (!existing) {
|
|
||||||
log.warn("reject for unknown request", { requestID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pending.delete(requestID)
|
|
||||||
log.info("rejected", { requestID })
|
|
||||||
Bus.publish(Event.Rejected, {
|
|
||||||
sessionID: existing.info.sessionID,
|
|
||||||
requestID: existing.info.id,
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = Effect.fn("Question.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (x) => x.info)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, reject, list })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
requestID: PermissionID.zod,
|
requestID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
|
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
|
||||||
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 PermissionNext.reply({
|
await Permission.reply({
|
||||||
requestID: params.requestID,
|
requestID: params.requestID,
|
||||||
reply: json.reply,
|
reply: json.reply,
|
||||||
message: json.message,
|
message: json.message,
|
||||||
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
|
|||||||
description: "List of pending permissions",
|
description: "List of pending permissions",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(PermissionNext.Request.array()),
|
schema: resolver(Permission.Request.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const permissions = await PermissionNext.list()
|
const permissions = await Permission.list()
|
||||||
return c.json(permissions)
|
return c.json(permissions)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(Pty.list())
|
return c.json(await Pty.list())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
@@ -75,7 +75,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const info = Pty.get(c.req.valid("param").ptyID)
|
const info = await Pty.get(c.req.valid("param").ptyID)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new NotFoundError({ message: "Session not found" })
|
throw new NotFoundError({ message: "Session not found" })
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ export const PtyRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||||
upgradeWebSocket((c) => {
|
upgradeWebSocket(async (c) => {
|
||||||
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
||||||
const cursor = (() => {
|
const cursor = (() => {
|
||||||
const value = c.req.query("cursor")
|
const value = c.req.query("cursor")
|
||||||
@@ -159,8 +159,8 @@ export const PtyRoutes = lazy(() =>
|
|||||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||||
return parsed
|
return parsed
|
||||||
})()
|
})()
|
||||||
let handler: ReturnType<typeof Pty.connect>
|
let handler: Awaited<ReturnType<typeof Pty.connect>>
|
||||||
if (!Pty.get(id)) throw new Error("Session not found")
|
if (!(await Pty.get(id))) throw new Error("Session not found")
|
||||||
|
|
||||||
type Socket = {
|
type Socket = {
|
||||||
readyState: number
|
readyState: number
|
||||||
@@ -176,17 +176,27 @@ export const PtyRoutes = lazy(() =>
|
|||||||
return typeof (value as { readyState?: unknown }).readyState === "number"
|
return typeof (value as { readyState?: unknown }).readyState === "number"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pending: string[] = []
|
||||||
|
let ready = false
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onOpen(_event, ws) {
|
async onOpen(_event, ws) {
|
||||||
const socket = ws.raw
|
const socket = ws.raw
|
||||||
if (!isSocket(socket)) {
|
if (!isSocket(socket)) {
|
||||||
ws.close()
|
ws.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler = Pty.connect(id, socket, cursor)
|
handler = await Pty.connect(id, socket, cursor)
|
||||||
|
ready = true
|
||||||
|
for (const msg of pending) handler?.onMessage(msg)
|
||||||
|
pending.length = 0
|
||||||
},
|
},
|
||||||
onMessage(event) {
|
onMessage(event) {
|
||||||
if (typeof event.data !== "string") return
|
if (typeof event.data !== "string") return
|
||||||
|
if (!ready) {
|
||||||
|
pending.push(event.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
handler?.onMessage(event.data)
|
handler?.onMessage(event.data)
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
|
|||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
@@ -88,8 +88,8 @@ export const SessionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const result = SessionStatus.list()
|
const result = await SessionStatus.list()
|
||||||
return c.json(result)
|
return c.json(Object.fromEntries(result))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
|
|||||||
permissionID: PermissionID.zod,
|
permissionID: PermissionID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", z.object({ response: PermissionNext.Reply })),
|
validator("json", z.object({ response: Permission.Reply })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
PermissionNext.reply({
|
Permission.reply({
|
||||||
requestID: params.permissionID,
|
requestID: params.permissionID,
|
||||||
reply: c.req.valid("json").response,
|
reply: c.req.valid("json").response,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import { Format } from "../format"
|
|||||||
import { TuiRoutes } from "./routes/tui"
|
import { TuiRoutes } from "./routes/tui"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Vcs } from "../project/vcs"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Skill } from "../skill/skill"
|
import { Skill } from "../skill"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
@@ -57,8 +56,14 @@ export namespace Server {
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
return app
|
return app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
const stack = err instanceof Error ? err.stack : undefined
|
||||||
|
const named = err instanceof NamedError ? err.toObject() : undefined
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
error: err,
|
error: err,
|
||||||
|
message: msg,
|
||||||
|
stack: stack ? JSON.stringify(stack) : undefined,
|
||||||
|
named,
|
||||||
})
|
})
|
||||||
if (err instanceof NamedError) {
|
if (err instanceof NamedError) {
|
||||||
let status: ContentfulStatusCode
|
let status: ContentfulStatusCode
|
||||||
@@ -152,7 +157,7 @@ export namespace Server {
|
|||||||
providerID: ProviderID.zod,
|
providerID: ProviderID.zod,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator("json", Auth.Info),
|
validator("json", Auth.Info.zod),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const providerID = c.req.valid("param").providerID
|
const providerID = c.req.valid("param").providerID
|
||||||
const info = c.req.valid("json")
|
const info = c.req.valid("json")
|
||||||
@@ -331,7 +336,7 @@ export namespace Server {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
|
const branch = await Vcs.branch()
|
||||||
return c.json({
|
return c.json({
|
||||||
branch,
|
branch,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
|
|||||||
|
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
@@ -148,7 +148,7 @@ export namespace Session {
|
|||||||
compacting: z.number().optional(),
|
compacting: z.number().optional(),
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
permission: PermissionNext.Ruleset.optional(),
|
permission: Permission.Ruleset.optional(),
|
||||||
revert: z
|
revert: z
|
||||||
.object({
|
.object({
|
||||||
messageID: MessageID.zod,
|
messageID: MessageID.zod,
|
||||||
@@ -300,7 +300,7 @@ export namespace Session {
|
|||||||
parentID?: SessionID
|
parentID?: SessionID
|
||||||
workspaceID?: WorkspaceID
|
workspaceID?: WorkspaceID
|
||||||
directory: string
|
directory: string
|
||||||
permission?: PermissionNext.Ruleset
|
permission?: Permission.Ruleset
|
||||||
}) {
|
}) {
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: SessionID.descending(input.id),
|
id: SessionID.descending(input.id),
|
||||||
@@ -423,7 +423,7 @@ export namespace Session {
|
|||||||
export const setPermission = fn(
|
export const setPermission = fn(
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionID.zod,
|
sessionID: SessionID.zod,
|
||||||
permission: PermissionNext.Ruleset,
|
permission: Permission.Ruleset,
|
||||||
}),
|
}),
|
||||||
async (input) => {
|
async (input) => {
|
||||||
return Database.use((db) => {
|
return Database.use((db) => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
|
|||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
@@ -33,7 +33,7 @@ export namespace LLM {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
permission?: PermissionNext.Ruleset
|
permission?: Permission.Ruleset
|
||||||
system: string[]
|
system: string[]
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
messages: ModelMessage[]
|
messages: ModelMessage[]
|
||||||
@@ -286,9 +286,9 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||||
const disabled = PermissionNext.disabled(
|
const disabled = Permission.disabled(
|
||||||
Object.keys(input.tools),
|
Object.keys(input.tools),
|
||||||
PermissionNext.merge(input.agent.permission, input.permission ?? []),
|
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||||
)
|
)
|
||||||
for (const tool of Object.keys(input.tools)) {
|
for (const tool of Object.keys(input.tools)) {
|
||||||
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
|
|||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
import type { SessionID, MessageID } from "./schema"
|
import type { SessionID, MessageID } from "./schema"
|
||||||
@@ -57,7 +57,7 @@ export namespace SessionProcessor {
|
|||||||
input.abort.throwIfAborted()
|
input.abort.throwIfAborted()
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case "start":
|
case "start":
|
||||||
SessionStatus.set(input.sessionID, { type: "busy" })
|
await SessionStatus.set(input.sessionID, { type: "busy" })
|
||||||
break
|
break
|
||||||
|
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const agent = await Agent.get(input.assistantMessage.agent)
|
const agent = await Agent.get(input.assistantMessage.agent)
|
||||||
await PermissionNext.ask({
|
await Permission.ask({
|
||||||
permission: "doom_loop",
|
permission: "doom_loop",
|
||||||
patterns: [value.toolName],
|
patterns: [value.toolName],
|
||||||
sessionID: input.assistantMessage.sessionID,
|
sessionID: input.assistantMessage.sessionID,
|
||||||
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
value.error instanceof PermissionNext.RejectedError ||
|
value.error instanceof Permission.RejectedError ||
|
||||||
value.error instanceof Question.RejectedError
|
value.error instanceof Question.RejectedError
|
||||||
) {
|
) {
|
||||||
blocked = shouldBreak
|
blocked = shouldBreak
|
||||||
@@ -368,7 +368,7 @@ export namespace SessionProcessor {
|
|||||||
if (retry !== undefined) {
|
if (retry !== undefined) {
|
||||||
attempt++
|
attempt++
|
||||||
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
|
||||||
SessionStatus.set(input.sessionID, {
|
await SessionStatus.set(input.sessionID, {
|
||||||
type: "retry",
|
type: "retry",
|
||||||
attempt,
|
attempt,
|
||||||
message: retry,
|
message: retry,
|
||||||
@@ -382,7 +382,7 @@ export namespace SessionProcessor {
|
|||||||
sessionID: input.assistantMessage.sessionID,
|
sessionID: input.assistantMessage.sessionID,
|
||||||
error: input.assistantMessage.error,
|
error: input.assistantMessage.error,
|
||||||
})
|
})
|
||||||
SessionStatus.set(input.sessionID, { type: "idle" })
|
await SessionStatus.set(input.sessionID, { type: "idle" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
|
|||||||
import { SessionProcessor } from "./processor"
|
import { SessionProcessor } from "./processor"
|
||||||
import { TaskTool } from "@/tool/task"
|
import { TaskTool } from "@/tool/task"
|
||||||
import { Tool } from "@/tool/tool"
|
import { Tool } from "@/tool/tool"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { SessionStatus } from "./status"
|
import { SessionStatus } from "./status"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
// this is backwards compatibility for allowing `tools` to be specified when
|
// this is backwards compatibility for allowing `tools` to be specified when
|
||||||
// prompting
|
// prompting
|
||||||
const permissions: PermissionNext.Ruleset = []
|
const permissions: Permission.Ruleset = []
|
||||||
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
|
||||||
permissions.push({
|
permissions.push({
|
||||||
permission: tool,
|
permission: tool,
|
||||||
@@ -257,17 +257,17 @@ export namespace SessionPrompt {
|
|||||||
return s[sessionID].abort.signal
|
return s[sessionID].abort.signal
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancel(sessionID: SessionID) {
|
export async function cancel(sessionID: SessionID) {
|
||||||
log.info("cancel", { sessionID })
|
log.info("cancel", { sessionID })
|
||||||
const s = state()
|
const s = state()
|
||||||
const match = s[sessionID]
|
const match = s[sessionID]
|
||||||
if (!match) {
|
if (!match) {
|
||||||
SessionStatus.set(sessionID, { type: "idle" })
|
await SessionStatus.set(sessionID, { type: "idle" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
match.abort.abort()
|
match.abort.abort()
|
||||||
delete s[sessionID]
|
delete s[sessionID]
|
||||||
SessionStatus.set(sessionID, { type: "idle" })
|
await SessionStatus.set(sessionID, { type: "idle" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ export namespace SessionPrompt {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
using _ = defer(() => cancel(sessionID))
|
await using _ = defer(() => cancel(sessionID))
|
||||||
|
|
||||||
// Structured output state
|
// Structured output state
|
||||||
// Note: On session resumption, state is reset but outputFormat is preserved
|
// Note: On session resumption, state is reset but outputFormat is preserved
|
||||||
@@ -296,7 +296,7 @@ export namespace SessionPrompt {
|
|||||||
let step = 0
|
let step = 0
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
SessionStatus.set(sessionID, { type: "busy" })
|
await SessionStatus.set(sessionID, { type: "busy" })
|
||||||
log.info("loop", { step, sessionID })
|
log.info("loop", { step, sessionID })
|
||||||
if (abort.aborted) break
|
if (abort.aborted) break
|
||||||
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||||
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
|
|||||||
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
|
||||||
},
|
},
|
||||||
async ask(req) {
|
async ask(req) {
|
||||||
await PermissionNext.ask({
|
await Permission.ask({
|
||||||
...req,
|
...req,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
|
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async ask(req) {
|
async ask(req) {
|
||||||
await PermissionNext.ask({
|
await Permission.ask({
|
||||||
...req,
|
...req,
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||||
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
|
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
|
|||||||
|
|
||||||
if (part.type === "agent") {
|
if (part.type === "agent") {
|
||||||
// Check if this agent would be denied by task permission
|
// Check if this agent would be denied by task permission
|
||||||
const perm = PermissionNext.evaluate("task", part.name, agent.permission)
|
const perm = Permission.evaluate("task", part.name, agent.permission)
|
||||||
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
export async function command(input: CommandInput) {
|
export async function command(input: CommandInput) {
|
||||||
log.info("command", input)
|
log.info("command", input)
|
||||||
const command = await Command.get(input.command)
|
const command = await Command.get(input.command)
|
||||||
|
if (!command) {
|
||||||
|
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
|
||||||
|
}
|
||||||
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
||||||
|
|
||||||
const raw = input.arguments.match(argsRegex) ?? []
|
const raw = input.arguments.match(argsRegex) ?? []
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
|
|||||||
import { ProjectTable } from "../project/project.sql"
|
import { ProjectTable } from "../project/project.sql"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import type { Snapshot } from "../snapshot"
|
import type { Snapshot } from "../snapshot"
|
||||||
import type { PermissionNext } from "../permission"
|
import type { Permission } from "../permission"
|
||||||
import type { ProjectID } from "../project/schema"
|
import type { ProjectID } from "../project/schema"
|
||||||
import type { SessionID, MessageID, PartID } from "./schema"
|
import type { SessionID, MessageID, PartID } from "./schema"
|
||||||
import type { WorkspaceID } from "../control-plane/schema"
|
import type { WorkspaceID } from "../control-plane/schema"
|
||||||
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
|
|||||||
summary_files: integer(),
|
summary_files: integer(),
|
||||||
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
|
||||||
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
|
||||||
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
|
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
time_compacting: integer(),
|
time_compacting: integer(),
|
||||||
time_archived: integer(),
|
time_archived: integer(),
|
||||||
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
|
data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Instance } from "@/project/instance"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { SessionID } from "./schema"
|
import { SessionID } from "./schema"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
export namespace SessionStatus {
|
export namespace SessionStatus {
|
||||||
@@ -42,36 +44,56 @@ export namespace SessionStatus {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Instance.state(() => {
|
export interface Interface {
|
||||||
const data: Record<string, Info> = {}
|
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
||||||
return data
|
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
||||||
})
|
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
||||||
|
|
||||||
export function get(sessionID: SessionID) {
|
|
||||||
return (
|
|
||||||
state()[sessionID] ?? {
|
|
||||||
type: "idle",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function list() {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
||||||
return state()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function set(sessionID: SessionID, status: Info) {
|
export const layer = Layer.effect(
|
||||||
Bus.publish(Event.Status, {
|
Service,
|
||||||
sessionID,
|
Effect.gen(function* () {
|
||||||
status,
|
const state = yield* InstanceState.make(
|
||||||
})
|
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
||||||
if (status.type === "idle") {
|
)
|
||||||
// deprecated
|
|
||||||
Bus.publish(Event.Idle, {
|
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
|
||||||
sessionID,
|
const data = yield* InstanceState.get(state)
|
||||||
|
return data.get(sessionID) ?? { type: "idle" as const }
|
||||||
})
|
})
|
||||||
delete state()[sessionID]
|
|
||||||
return
|
const list = Effect.fn("SessionStatus.list")(function* () {
|
||||||
}
|
return new Map(yield* InstanceState.get(state))
|
||||||
state()[sessionID] = status
|
})
|
||||||
|
|
||||||
|
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||||
|
const data = yield* InstanceState.get(state)
|
||||||
|
yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
|
||||||
|
if (status.type === "idle") {
|
||||||
|
yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
|
||||||
|
data.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.set(sessionID, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, list, set })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
|
export async function get(sessionID: SessionID) {
|
||||||
|
return runPromise((svc) => svc.get(sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return runPromise((svc) => svc.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function set(sessionID: SessionID, status: Info) {
|
||||||
|
return runPromise((svc) => svc.set(sessionID, status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
|
|||||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
|
|
||||||
export namespace SystemPrompt {
|
export namespace SystemPrompt {
|
||||||
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function skills(agent: Agent.Info) {
|
export async function skills(agent: Agent.Info) {
|
||||||
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
|
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||||
|
|
||||||
const list = await Skill.available(agent)
|
const list = await Skill.available(agent)
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export namespace ShareNext {
|
|||||||
}> {
|
}> {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
|
|
||||||
const active = Account.active()
|
const active = await Account.active()
|
||||||
if (!active?.active_org_id) {
|
if (!active?.active_org_id) {
|
||||||
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||||
return { headers, api: legacyApi, baseUrl }
|
return { headers, api: legacyApi, baseUrl }
|
||||||
|
|||||||
@@ -1 +1,262 @@
|
|||||||
export * from "./skill"
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { pathToFileURL } from "url"
|
||||||
|
import z from "zod"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
|
import type { Agent } from "@/agent/agent"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Global } from "@/global"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Discovery } from "./discovery"
|
||||||
|
|
||||||
|
export namespace Skill {
|
||||||
|
const log = Log.create({ service: "skill" })
|
||||||
|
const EXTERNAL_DIRS = [".claude", ".agents"]
|
||||||
|
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
||||||
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||||
|
const SKILL_PATTERN = "**/SKILL.md"
|
||||||
|
|
||||||
|
export const Info = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
location: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export const InvalidError = NamedError.create(
|
||||||
|
"SkillInvalidError",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NameMismatchError = NamedError.create(
|
||||||
|
"SkillNameMismatchError",
|
||||||
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
expected: z.string(),
|
||||||
|
actual: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
skills: Record<string, Info>
|
||||||
|
dirs: Set<string>
|
||||||
|
task?: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache = State & {
|
||||||
|
ensure: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
|
readonly all: () => Effect.Effect<Info[]>
|
||||||
|
readonly dirs: () => Effect.Effect<string[]>
|
||||||
|
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = async (state: State, match: string) => {
|
||||||
|
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||||
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||||
|
? err.data.message
|
||||||
|
: `Failed to parse skill ${match}`
|
||||||
|
const { Session } = await import("@/session")
|
||||||
|
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||||
|
log.error("failed to load skill", { skill: match, err })
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!md) return
|
||||||
|
|
||||||
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
|
if (!parsed.success) return
|
||||||
|
|
||||||
|
if (state.skills[parsed.data.name]) {
|
||||||
|
log.warn("duplicate skill name", {
|
||||||
|
name: parsed.data.name,
|
||||||
|
existing: state.skills[parsed.data.name].location,
|
||||||
|
duplicate: match,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dirs.add(path.dirname(match))
|
||||||
|
state.skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
content: md.content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||||
|
return Glob.scan(pattern, {
|
||||||
|
cwd: root,
|
||||||
|
absolute: true,
|
||||||
|
include: "file",
|
||||||
|
symlink: true,
|
||||||
|
dot: opts?.dot,
|
||||||
|
})
|
||||||
|
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!opts?.scope) throw error
|
||||||
|
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Migrate to Effect
|
||||||
|
const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
|
||||||
|
const state: State = {
|
||||||
|
skills: {},
|
||||||
|
dirs: new Set<string>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
|
const root = path.join(Global.Path.home, dir)
|
||||||
|
if (!(await Filesystem.isDir(root))) continue
|
||||||
|
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const root of Filesystem.up({
|
||||||
|
targets: EXTERNAL_DIRS,
|
||||||
|
start: directory,
|
||||||
|
stop: worktree,
|
||||||
|
})) {
|
||||||
|
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of await Config.directories()) {
|
||||||
|
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = await Config.get()
|
||||||
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
|
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||||
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||||
|
if (!(await Filesystem.isDir(dir))) {
|
||||||
|
log.warn("skill path not found", { path: dir })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await scan(state, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of cfg.skills?.urls ?? []) {
|
||||||
|
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||||
|
state.dirs.add(dir)
|
||||||
|
await scan(state, dir, SKILL_PATTERN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("init", { count: Object.keys(state.skills).length })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensure = () => {
|
||||||
|
if (state.task) return state.task
|
||||||
|
state.task = load().catch((err) => {
|
||||||
|
state.task = undefined
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
return state.task
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...state, ensure }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const discovery = yield* Discovery.Service
|
||||||
|
const state = yield* InstanceState.make(
|
||||||
|
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ensure = Effect.fn("Skill.ensure")(function* () {
|
||||||
|
const cache = yield* InstanceState.get(state)
|
||||||
|
yield* Effect.promise(() => cache.ensure())
|
||||||
|
return cache
|
||||||
|
})
|
||||||
|
|
||||||
|
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||||
|
const cache = yield* ensure()
|
||||||
|
return cache.skills[name]
|
||||||
|
})
|
||||||
|
|
||||||
|
const all = Effect.fn("Skill.all")(function* () {
|
||||||
|
const cache = yield* ensure()
|
||||||
|
return Object.values(cache.skills)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||||
|
const cache = yield* ensure()
|
||||||
|
return Array.from(cache.dirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||||
|
const cache = yield* ensure()
|
||||||
|
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
if (!agent) return list
|
||||||
|
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ get, all, dirs, available })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
|
||||||
|
|
||||||
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
|
if (list.length === 0) return "No skills are currently available."
|
||||||
|
|
||||||
|
if (opts.verbose) {
|
||||||
|
return [
|
||||||
|
"<available_skills>",
|
||||||
|
...list.flatMap((skill) => [
|
||||||
|
" <skill>",
|
||||||
|
` <name>${skill.name}</name>`,
|
||||||
|
` <description>${skill.description}</description>`,
|
||||||
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||||
|
" </skill>",
|
||||||
|
]),
|
||||||
|
"</available_skills>",
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function get(name: string) {
|
||||||
|
return runPromise((skill) => skill.get(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function all() {
|
||||||
|
return runPromise((skill) => skill.all())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dirs() {
|
||||||
|
return runPromise((skill) => skill.dirs())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function available(agent?: Agent.Info) {
|
||||||
|
return runPromise((skill) => skill.available(agent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import os from "os"
|
|
||||||
import path from "path"
|
|
||||||
import { pathToFileURL } from "url"
|
|
||||||
import z from "zod"
|
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
|
||||||
import type { Agent } from "@/agent/agent"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { Flag } from "@/flag/flag"
|
|
||||||
import { Global } from "@/global"
|
|
||||||
import { Permission } from "@/permission/service"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { Discovery } from "./discovery"
|
|
||||||
|
|
||||||
export namespace Skill {
|
|
||||||
const log = Log.create({ service: "skill" })
|
|
||||||
const EXTERNAL_DIRS = [".claude", ".agents"]
|
|
||||||
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
|
||||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
|
||||||
const SKILL_PATTERN = "**/SKILL.md"
|
|
||||||
|
|
||||||
export const Info = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
location: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export const InvalidError = NamedError.create(
|
|
||||||
"SkillInvalidError",
|
|
||||||
z.object({
|
|
||||||
path: z.string(),
|
|
||||||
message: z.string().optional(),
|
|
||||||
issues: z.custom<z.core.$ZodIssue[]>().optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const NameMismatchError = NamedError.create(
|
|
||||||
"SkillNameMismatchError",
|
|
||||||
z.object({
|
|
||||||
path: z.string(),
|
|
||||||
expected: z.string(),
|
|
||||||
actual: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
skills: Record<string, Info>
|
|
||||||
dirs: Set<string>
|
|
||||||
task?: Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cache = State & {
|
|
||||||
ensure: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
|
||||||
readonly all: () => Effect.Effect<Info[]>
|
|
||||||
readonly dirs: () => Effect.Effect<string[]>
|
|
||||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const add = async (state: State, match: string) => {
|
|
||||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
|
||||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
||||||
? err.data.message
|
|
||||||
: `Failed to parse skill ${match}`
|
|
||||||
const { Session } = await import("@/session")
|
|
||||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
||||||
log.error("failed to load skill", { skill: match, err })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!md) return
|
|
||||||
|
|
||||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
|
||||||
if (!parsed.success) return
|
|
||||||
|
|
||||||
if (state.skills[parsed.data.name]) {
|
|
||||||
log.warn("duplicate skill name", {
|
|
||||||
name: parsed.data.name,
|
|
||||||
existing: state.skills[parsed.data.name].location,
|
|
||||||
duplicate: match,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
state.dirs.add(path.dirname(match))
|
|
||||||
state.skills[parsed.data.name] = {
|
|
||||||
name: parsed.data.name,
|
|
||||||
description: parsed.data.description,
|
|
||||||
location: match,
|
|
||||||
content: md.content,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
|
||||||
return Glob.scan(pattern, {
|
|
||||||
cwd: root,
|
|
||||||
absolute: true,
|
|
||||||
include: "file",
|
|
||||||
symlink: true,
|
|
||||||
dot: opts?.dot,
|
|
||||||
})
|
|
||||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
|
||||||
.catch((error) => {
|
|
||||||
if (!opts?.scope) throw error
|
|
||||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Migrate to Effect
|
|
||||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
|
||||||
const state: State = {
|
|
||||||
skills: {},
|
|
||||||
dirs: new Set<string>(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
|
||||||
const root = path.join(Global.Path.home, dir)
|
|
||||||
if (!(await Filesystem.isDir(root))) continue
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const root of Filesystem.up({
|
|
||||||
targets: EXTERNAL_DIRS,
|
|
||||||
start: instance.directory,
|
|
||||||
stop: instance.project.worktree,
|
|
||||||
})) {
|
|
||||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dir of await Config.directories()) {
|
|
||||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cfg = await Config.get()
|
|
||||||
for (const item of cfg.skills?.paths ?? []) {
|
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
|
||||||
if (!(await Filesystem.isDir(dir))) {
|
|
||||||
log.warn("skill path not found", { path: dir })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of cfg.skills?.urls ?? []) {
|
|
||||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
|
||||||
state.dirs.add(dir)
|
|
||||||
await scan(state, dir, SKILL_PATTERN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("init", { count: Object.keys(state.skills).length })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensure = () => {
|
|
||||||
if (state.task) return state.task
|
|
||||||
state.task = load().catch((err) => {
|
|
||||||
state.task = undefined
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
return state.task
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...state, ensure }
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const instance = yield* InstanceContext
|
|
||||||
const discovery = yield* Discovery.Service
|
|
||||||
const state = create(instance, discovery)
|
|
||||||
|
|
||||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return state.skills[name]
|
|
||||||
})
|
|
||||||
|
|
||||||
const all = Effect.fn("Skill.all")(function* () {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return Object.values(state.skills)
|
|
||||||
})
|
|
||||||
|
|
||||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
return Array.from(state.dirs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
|
||||||
yield* Effect.promise(() => state.ensure())
|
|
||||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
||||||
if (!agent) return list
|
|
||||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, all, dirs, available })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
|
||||||
Layer.provide(Discovery.defaultLayer),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
|
||||||
if (list.length === 0) return "No skills are currently available."
|
|
||||||
|
|
||||||
if (opts.verbose) {
|
|
||||||
return [
|
|
||||||
"<available_skills>",
|
|
||||||
...list.flatMap((skill) => [
|
|
||||||
" <skill>",
|
|
||||||
` <name>${skill.name}</name>`,
|
|
||||||
` <description>${skill.description}</description>`,
|
|
||||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
||||||
" </skill>",
|
|
||||||
]),
|
|
||||||
"</available_skills>",
|
|
||||||
].join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
|
||||||
import type { Agent } from "@/agent/agent"
|
|
||||||
import { Skill as S } from "./service"
|
|
||||||
|
|
||||||
export namespace Skill {
|
|
||||||
export const Info = S.Info
|
|
||||||
export type Info = S.Info
|
|
||||||
|
|
||||||
export const InvalidError = S.InvalidError
|
|
||||||
export const NameMismatchError = S.NameMismatchError
|
|
||||||
|
|
||||||
export type Interface = S.Interface
|
|
||||||
|
|
||||||
export const Service = S.Service
|
|
||||||
export const layer = S.layer
|
|
||||||
export const defaultLayer = S.defaultLayer
|
|
||||||
|
|
||||||
export const fmt = S.fmt
|
|
||||||
|
|
||||||
export async function get(name: string) {
|
|
||||||
return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function all() {
|
|
||||||
return runPromiseInstance(S.Service.use((skill) => skill.all()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dirs() {
|
|
||||||
return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function available(agent?: Agent.Info) {
|
|
||||||
return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,396 @@
|
|||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Snapshot as S } from "./service"
|
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { Config } from "../config/config"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace Snapshot {
|
export namespace Snapshot {
|
||||||
export const Patch = S.Patch
|
export const Patch = z.object({
|
||||||
export type Patch = S.Patch
|
hash: z.string(),
|
||||||
|
files: z.string().array(),
|
||||||
|
})
|
||||||
|
export type Patch = z.infer<typeof Patch>
|
||||||
|
|
||||||
export const FileDiff = S.FileDiff
|
export const FileDiff = z
|
||||||
export type FileDiff = S.FileDiff
|
.object({
|
||||||
|
file: z.string(),
|
||||||
|
before: z.string(),
|
||||||
|
after: z.string(),
|
||||||
|
additions: z.number(),
|
||||||
|
deletions: z.number(),
|
||||||
|
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "FileDiff",
|
||||||
|
})
|
||||||
|
export type FileDiff = z.infer<typeof FileDiff>
|
||||||
|
|
||||||
export type Interface = S.Interface
|
const log = Log.create({ service: "snapshot" })
|
||||||
|
const prune = "7.days"
|
||||||
|
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||||
|
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||||
|
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||||
|
|
||||||
export const Service = S.Service
|
interface GitResult {
|
||||||
export const layer = S.layer
|
readonly code: ChildProcessSpawner.ExitCode
|
||||||
export const defaultLayer = S.defaultLayer
|
readonly text: string
|
||||||
|
readonly stderr: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = Omit<Interface, "init">
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
|
readonly track: () => Effect.Effect<string | undefined>
|
||||||
|
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
||||||
|
readonly restore: (snapshot: string) => Effect.Effect<void>
|
||||||
|
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
||||||
|
readonly diff: (hash: string) => Effect.Effect<string>
|
||||||
|
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
|
||||||
|
Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
|
const state = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||||
|
const state = {
|
||||||
|
directory: ctx.directory,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
|
||||||
|
vcs: ctx.project.vcs,
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||||
|
|
||||||
|
const git = Effect.fnUntraced(
|
||||||
|
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||||
|
const proc = ChildProcess.make("git", cmd, {
|
||||||
|
cwd: opts?.cwd,
|
||||||
|
env: opts?.env,
|
||||||
|
extendEnv: true,
|
||||||
|
})
|
||||||
|
const handle = yield* spawner.spawn(proc)
|
||||||
|
const [text, stderr] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||||
|
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const code = yield* handle.exitCode
|
||||||
|
return { code, text, stderr } satisfies GitResult
|
||||||
|
},
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.catch((err) =>
|
||||||
|
Effect.succeed({
|
||||||
|
code: ChildProcessSpawner.ExitCode(1),
|
||||||
|
text: "",
|
||||||
|
stderr: String(err),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||||
|
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
|
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||||
|
|
||||||
|
const enabled = Effect.fnUntraced(function* () {
|
||||||
|
if (state.vcs !== "git") return false
|
||||||
|
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||||
|
})
|
||||||
|
|
||||||
|
const excludes = Effect.fnUntraced(function* () {
|
||||||
|
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
const file = result.text.trim()
|
||||||
|
if (!file) return
|
||||||
|
if (!(yield* exists(file))) return
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
|
||||||
|
const sync = Effect.fnUntraced(function* () {
|
||||||
|
const file = yield* excludes()
|
||||||
|
const target = path.join(state.gitdir, "info", "exclude")
|
||||||
|
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||||
|
if (!file) {
|
||||||
|
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const add = Effect.fnUntraced(function* () {
|
||||||
|
yield* sync()
|
||||||
|
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanup = Effect.fnUntraced(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
|
if (!(yield* exists(state.gitdir))) return
|
||||||
|
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("cleanup failed", {
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("cleanup", { prune })
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = Effect.fnUntraced(function* () {
|
||||||
|
if (!(yield* enabled())) return
|
||||||
|
const existed = yield* exists(state.gitdir)
|
||||||
|
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||||
|
if (!existed) {
|
||||||
|
yield* git(["init"], {
|
||||||
|
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||||
|
})
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||||
|
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||||
|
log.info("initialized")
|
||||||
|
}
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||||
|
const hash = result.text.trim()
|
||||||
|
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||||
|
return hash
|
||||||
|
})
|
||||||
|
|
||||||
|
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||||
|
return { hash, files: [] }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
files: result.text
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||||
|
log.info("restore", { commit: snapshot })
|
||||||
|
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||||
|
if (result.code === 0) {
|
||||||
|
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
|
||||||
|
if (checkout.code === 0) return
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: checkout.code,
|
||||||
|
stderr: checkout.stderr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.error("failed to restore snapshot", {
|
||||||
|
snapshot,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const item of patches) {
|
||||||
|
for (const file of item.files) {
|
||||||
|
if (seen.has(file)) continue
|
||||||
|
seen.add(file)
|
||||||
|
log.info("reverting", { file, hash: item.hash })
|
||||||
|
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const rel = path.relative(state.worktree, file)
|
||||||
|
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (tree.code === 0 && tree.text.trim()) {
|
||||||
|
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||||
|
} else {
|
||||||
|
log.info("file did not exist in snapshot, deleting", { file })
|
||||||
|
yield* remove(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||||
|
yield* add()
|
||||||
|
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||||
|
cwd: state.worktree,
|
||||||
|
})
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log.warn("failed to get diff", {
|
||||||
|
hash,
|
||||||
|
exitCode: result.code,
|
||||||
|
stderr: result.stderr,
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.text.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||||
|
const result: Snapshot.FileDiff[] = []
|
||||||
|
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||||
|
|
||||||
|
const statuses = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||||
|
{ cwd: state.directory },
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const line of statuses.text.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [code, file] = line.split("\t")
|
||||||
|
if (!code || !file) continue
|
||||||
|
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
const numstat = yield* git(
|
||||||
|
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||||
|
{
|
||||||
|
cwd: state.directory,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const line of numstat.text.trim().split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
const [adds, dels, file] = line.split("\t")
|
||||||
|
if (!file) continue
|
||||||
|
const binary = adds === "-" && dels === "-"
|
||||||
|
const [before, after] = binary
|
||||||
|
? ["", ""]
|
||||||
|
: yield* Effect.all(
|
||||||
|
[
|
||||||
|
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||||
|
],
|
||||||
|
{ concurrency: 2 },
|
||||||
|
)
|
||||||
|
const additions = binary ? 0 : parseInt(adds)
|
||||||
|
const deletions = binary ? 0 : parseInt(dels)
|
||||||
|
result.push({
|
||||||
|
file,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
additions: Number.isFinite(additions) ? additions : 0,
|
||||||
|
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||||
|
status: status.get(file) ?? "modified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* cleanup().pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||||
|
Effect.delay(Duration.minutes(1)),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
init: Effect.fn("Snapshot.init")(function* () {
|
||||||
|
yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||||
|
}),
|
||||||
|
track: Effect.fn("Snapshot.track")(function* () {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||||
|
}),
|
||||||
|
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||||
|
}),
|
||||||
|
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||||
|
}),
|
||||||
|
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||||
|
}),
|
||||||
|
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||||
|
}),
|
||||||
|
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||||
|
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(NodeChildProcessSpawner.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||||
|
Layer.provide(NodePath.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
return runPromise((svc) => svc.init())
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanup() {
|
export async function cleanup() {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
|
return runPromise((svc) => svc.cleanup())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function track() {
|
export async function track() {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.track()))
|
return runPromise((svc) => svc.track())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patch(hash: string) {
|
export async function patch(hash: string) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
|
return runPromise((svc) => svc.patch(hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore(snapshot: string) {
|
export async function restore(snapshot: string) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
|
return runPromise((svc) => svc.restore(snapshot))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revert(patches: Patch[]) {
|
export async function revert(patches: Patch[]) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
|
return runPromise((svc) => svc.revert(patches))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diff(hash: string) {
|
export async function diff(hash: string) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
|
return runPromise((svc) => svc.diff(hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function diffFull(from: string, to: string) {
|
export async function diffFull(from: string, to: string) {
|
||||||
return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
|
return runPromise((svc) => svc.diffFull(from, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
|
||||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
|
||||||
import { Config } from "../config/config"
|
|
||||||
import { Global } from "../global"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace Snapshot {
|
|
||||||
export const Patch = z.object({
|
|
||||||
hash: z.string(),
|
|
||||||
files: z.string().array(),
|
|
||||||
})
|
|
||||||
export type Patch = z.infer<typeof Patch>
|
|
||||||
|
|
||||||
export const FileDiff = z
|
|
||||||
.object({
|
|
||||||
file: z.string(),
|
|
||||||
before: z.string(),
|
|
||||||
after: z.string(),
|
|
||||||
additions: z.number(),
|
|
||||||
deletions: z.number(),
|
|
||||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "FileDiff",
|
|
||||||
})
|
|
||||||
export type FileDiff = z.infer<typeof FileDiff>
|
|
||||||
|
|
||||||
const log = Log.create({ service: "snapshot" })
|
|
||||||
const prune = "7.days"
|
|
||||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
|
||||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
|
||||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
|
||||||
|
|
||||||
interface GitResult {
|
|
||||||
readonly code: ChildProcessSpawner.ExitCode
|
|
||||||
readonly text: string
|
|
||||||
readonly stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
readonly track: () => Effect.Effect<string | undefined>
|
|
||||||
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
|
|
||||||
readonly restore: (snapshot: string) => Effect.Effect<void>
|
|
||||||
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
|
|
||||||
readonly diff: (hash: string) => Effect.Effect<string>
|
|
||||||
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
|
||||||
|
|
||||||
export const layer: Layer.Layer<
|
|
||||||
Service,
|
|
||||||
never,
|
|
||||||
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
> = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = yield* InstanceContext
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
||||||
const directory = ctx.directory
|
|
||||||
const worktree = ctx.worktree
|
|
||||||
const project = ctx.project
|
|
||||||
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
|
|
||||||
|
|
||||||
const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
|
|
||||||
|
|
||||||
const git = Effect.fnUntraced(
|
|
||||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
|
||||||
const proc = ChildProcess.make("git", cmd, {
|
|
||||||
cwd: opts?.cwd,
|
|
||||||
env: opts?.env,
|
|
||||||
extendEnv: true,
|
|
||||||
})
|
|
||||||
const handle = yield* spawner.spawn(proc)
|
|
||||||
const [text, stderr] = yield* Effect.all(
|
|
||||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const code = yield* handle.exitCode
|
|
||||||
return { code, text, stderr } satisfies GitResult
|
|
||||||
},
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.catch((err) =>
|
|
||||||
Effect.succeed({
|
|
||||||
code: ChildProcessSpawner.ExitCode(1),
|
|
||||||
text: "",
|
|
||||||
stderr: String(err),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Snapshot-specific error handling on top of AppFileSystem
|
|
||||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
|
||||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
|
||||||
|
|
||||||
const enabled = Effect.fnUntraced(function* () {
|
|
||||||
if (project.vcs !== "git") return false
|
|
||||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludes = Effect.fnUntraced(function* () {
|
|
||||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
|
||||||
cwd: worktree,
|
|
||||||
})
|
|
||||||
const file = result.text.trim()
|
|
||||||
if (!file) return
|
|
||||||
if (!(yield* exists(file))) return
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
|
|
||||||
const sync = Effect.fnUntraced(function* () {
|
|
||||||
const file = yield* excludes()
|
|
||||||
const target = path.join(gitdir, "info", "exclude")
|
|
||||||
yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
|
|
||||||
if (!file) {
|
|
||||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* () {
|
|
||||||
yield* sync()
|
|
||||||
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
|
|
||||||
})
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
if (!(yield* exists(gitdir))) return
|
|
||||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("cleanup failed", {
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.info("cleanup", { prune })
|
|
||||||
})
|
|
||||||
|
|
||||||
const track = Effect.fn("Snapshot.track")(function* () {
|
|
||||||
if (!(yield* enabled())) return
|
|
||||||
const existed = yield* exists(gitdir)
|
|
||||||
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
|
|
||||||
if (!existed) {
|
|
||||||
yield* git(["init"], {
|
|
||||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
|
||||||
})
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
|
|
||||||
yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
|
|
||||||
log.info("initialized")
|
|
||||||
}
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git(args(["write-tree"]), { cwd: directory })
|
|
||||||
const hash = result.text.trim()
|
|
||||||
log.info("tracking", { hash, cwd: directory, git: gitdir })
|
|
||||||
return hash
|
|
||||||
})
|
|
||||||
|
|
||||||
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
|
|
||||||
cwd: directory,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
|
||||||
return { hash, files: [] }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
hash,
|
|
||||||
files: result.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
|
||||||
log.info("restore", { commit: snapshot })
|
|
||||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
|
|
||||||
if (result.code === 0) {
|
|
||||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
|
|
||||||
if (checkout.code === 0) return
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: checkout.code,
|
|
||||||
stderr: checkout.stderr,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.error("failed to restore snapshot", {
|
|
||||||
snapshot,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
for (const item of patches) {
|
|
||||||
for (const file of item.files) {
|
|
||||||
if (seen.has(file)) continue
|
|
||||||
seen.add(file)
|
|
||||||
log.info("reverting", { file, hash: item.hash })
|
|
||||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
|
|
||||||
if (result.code !== 0) {
|
|
||||||
const rel = path.relative(worktree, file)
|
|
||||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
|
|
||||||
if (tree.code === 0 && tree.text.trim()) {
|
|
||||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
|
||||||
} else {
|
|
||||||
log.info("file did not exist in snapshot, deleting", { file })
|
|
||||||
yield* remove(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
|
|
||||||
yield* add()
|
|
||||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
|
||||||
cwd: worktree,
|
|
||||||
})
|
|
||||||
if (result.code !== 0) {
|
|
||||||
log.warn("failed to get diff", {
|
|
||||||
hash,
|
|
||||||
exitCode: result.code,
|
|
||||||
stderr: result.stderr,
|
|
||||||
})
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return result.text.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
|
||||||
const result: Snapshot.FileDiff[] = []
|
|
||||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
|
||||||
|
|
||||||
const statuses = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
|
||||||
{ cwd: directory },
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of statuses.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [code, file] = line.split("\t")
|
|
||||||
if (!code || !file) continue
|
|
||||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
const numstat = yield* git(
|
|
||||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
|
||||||
{
|
|
||||||
cwd: directory,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const line of numstat.text.trim().split("\n")) {
|
|
||||||
if (!line) continue
|
|
||||||
const [adds, dels, file] = line.split("\t")
|
|
||||||
if (!file) continue
|
|
||||||
const binary = adds === "-" && dels === "-"
|
|
||||||
const [before, after] = binary
|
|
||||||
? ["", ""]
|
|
||||||
: yield* Effect.all(
|
|
||||||
[
|
|
||||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
|
||||||
],
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const additions = binary ? 0 : parseInt(adds)
|
|
||||||
const deletions = binary ? 0 : parseInt(dels)
|
|
||||||
result.push({
|
|
||||||
file,
|
|
||||||
before,
|
|
||||||
after,
|
|
||||||
additions: Number.isFinite(additions) ? additions : 0,
|
|
||||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
|
||||||
status: status.get(file) ?? "modified",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
|
|
||||||
}),
|
|
||||||
).pipe(Layer.fresh)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
|
||||||
Layer.provide(NodePath.layer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,10 @@ const log = Log.create({ service: "db" })
|
|||||||
|
|
||||||
export namespace Database {
|
export namespace Database {
|
||||||
export const Path = iife(() => {
|
export const Path = iife(() => {
|
||||||
|
if (Flag.OPENCODE_DB) {
|
||||||
|
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||||
|
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||||
|
}
|
||||||
const channel = Installation.CHANNEL
|
const channel = Installation.CHANNEL
|
||||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||||
return path.join(Global.Path.data, "opencode.db")
|
return path.join(Global.Path.data, "opencode.db")
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import { GrepTool } from "./grep"
|
|||||||
import { BatchTool } from "./batch"
|
import { BatchTool } from "./batch"
|
||||||
import { ReadTool } from "./read"
|
import { ReadTool } from "./read"
|
||||||
import { TaskTool } from "./task"
|
import { TaskTool } from "./task"
|
||||||
import { TodoWriteTool, TodoReadTool } from "./todo"
|
import { TodoWriteTool } from "./todo"
|
||||||
import { WebFetchTool } from "./webfetch"
|
import { WebFetchTool } from "./webfetch"
|
||||||
import { WriteTool } from "./write"
|
import { WriteTool } from "./write"
|
||||||
import { InvalidTool } from "./invalid"
|
import { InvalidTool } from "./invalid"
|
||||||
import { SkillTool } from "./skill"
|
import { SkillTool } from "./skill"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||||
@@ -27,106 +26,186 @@ import { Flag } from "@/flag/flag"
|
|||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncate"
|
||||||
|
|
||||||
import { ApplyPatchTool } from "./apply_patch"
|
import { ApplyPatchTool } from "./apply_patch"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
|
|
||||||
export const state = Instance.state(async () => {
|
type State = {
|
||||||
const custom = [] as Tool.Info[]
|
custom: Tool.Info[]
|
||||||
|
|
||||||
const matches = await Config.directories().then((dirs) =>
|
|
||||||
dirs.flatMap((dir) =>
|
|
||||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (matches.length) await Config.waitForDependencies()
|
|
||||||
for (const match of matches) {
|
|
||||||
const namespace = path.basename(match, path.extname(match))
|
|
||||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
|
||||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
|
||||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugins = await Plugin.list()
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
|
||||||
custom.push(fromPlugin(id, def))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { custom }
|
|
||||||
})
|
|
||||||
|
|
||||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
init: async (initCtx) => ({
|
|
||||||
parameters: z.object(def.args),
|
|
||||||
description: def.description,
|
|
||||||
execute: async (args, ctx) => {
|
|
||||||
const pluginCtx = {
|
|
||||||
...ctx,
|
|
||||||
directory: Instance.directory,
|
|
||||||
worktree: Instance.worktree,
|
|
||||||
} as unknown as PluginToolContext
|
|
||||||
const result = await def.execute(args as any, pluginCtx)
|
|
||||||
const out = await Truncate.output(result, {}, initCtx?.agent)
|
|
||||||
return {
|
|
||||||
title: "",
|
|
||||||
output: out.truncated ? out.content : result,
|
|
||||||
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly register: (tool: Tool.Info) => Effect.Effect<void>
|
||||||
|
readonly ids: () => Effect.Effect<string[]>
|
||||||
|
readonly tools: (
|
||||||
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
|
agent?: Agent.Info,
|
||||||
|
) => Effect.Effect<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cache = yield* InstanceState.make<State>(
|
||||||
|
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||||
|
const custom: Tool.Info[] = []
|
||||||
|
|
||||||
|
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
init: async (initCtx) => ({
|
||||||
|
parameters: z.object(def.args),
|
||||||
|
description: def.description,
|
||||||
|
execute: async (args, toolCtx) => {
|
||||||
|
const pluginCtx = {
|
||||||
|
...toolCtx,
|
||||||
|
directory: ctx.directory,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
} as unknown as PluginToolContext
|
||||||
|
const result = await def.execute(args as any, pluginCtx)
|
||||||
|
const out = await Truncate.output(result, {}, initCtx?.agent)
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
output: out.truncated ? out.content : result,
|
||||||
|
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
const matches = await Config.directories().then((dirs) =>
|
||||||
|
dirs.flatMap((dir) =>
|
||||||
|
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (matches.length) await Config.waitForDependencies()
|
||||||
|
for (const match of matches) {
|
||||||
|
const namespace = path.basename(match, path.extname(match))
|
||||||
|
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||||
|
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||||
|
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = await Plugin.list()
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
||||||
|
custom.push(fromPlugin(id, def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { custom }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function all(custom: Tool.Info[]): Promise<Tool.Info[]> {
|
||||||
|
const cfg = await Config.get()
|
||||||
|
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
|
|
||||||
|
return [
|
||||||
|
InvalidTool,
|
||||||
|
...(question ? [QuestionTool] : []),
|
||||||
|
BashTool,
|
||||||
|
ReadTool,
|
||||||
|
GlobTool,
|
||||||
|
GrepTool,
|
||||||
|
EditTool,
|
||||||
|
WriteTool,
|
||||||
|
TaskTool,
|
||||||
|
WebFetchTool,
|
||||||
|
TodoWriteTool,
|
||||||
|
WebSearchTool,
|
||||||
|
CodeSearchTool,
|
||||||
|
SkillTool,
|
||||||
|
ApplyPatchTool,
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||||
|
...(cfg.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||||
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||||
|
...custom,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const idx = state.custom.findIndex((t) => t.id === tool.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
state.custom.splice(idx, 1, tool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.custom.push(tool)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const tools = yield* Effect.promise(() => all(state.custom))
|
||||||
|
return tools.map((t) => t.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tools = Effect.fn("ToolRegistry.tools")(function* (
|
||||||
|
model: { providerID: ProviderID; modelID: ModelID },
|
||||||
|
agent?: Agent.Info,
|
||||||
|
) {
|
||||||
|
const state = yield* InstanceState.get(cache)
|
||||||
|
const allTools = yield* Effect.promise(() => all(state.custom))
|
||||||
|
return yield* Effect.promise(() =>
|
||||||
|
Promise.all(
|
||||||
|
allTools
|
||||||
|
.filter((tool) => {
|
||||||
|
// Enable websearch/codesearch for zen users OR via enable flag
|
||||||
|
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||||
|
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||||
|
}
|
||||||
|
|
||||||
|
// use apply tool in same format as codex
|
||||||
|
const usePatch =
|
||||||
|
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||||
|
if (tool.id === "apply_patch") return usePatch
|
||||||
|
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(async (tool) => {
|
||||||
|
using _ = log.time(tool.id)
|
||||||
|
const next = await tool.init({ agent })
|
||||||
|
const output = {
|
||||||
|
description: next.description,
|
||||||
|
parameters: next.parameters,
|
||||||
|
}
|
||||||
|
await Plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
...next,
|
||||||
|
description: output.description,
|
||||||
|
parameters: output.parameters,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ register, ids, tools })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, layer)
|
||||||
|
|
||||||
export async function register(tool: Tool.Info) {
|
export async function register(tool: Tool.Info) {
|
||||||
const { custom } = await state()
|
return runPromise((svc) => svc.register(tool))
|
||||||
const idx = custom.findIndex((t) => t.id === tool.id)
|
|
||||||
if (idx >= 0) {
|
|
||||||
custom.splice(idx, 1, tool)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
custom.push(tool)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function all(): Promise<Tool.Info[]> {
|
|
||||||
const custom = await state().then((x) => x.custom)
|
|
||||||
const config = await Config.get()
|
|
||||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
|
||||||
|
|
||||||
return [
|
|
||||||
InvalidTool,
|
|
||||||
...(question ? [QuestionTool] : []),
|
|
||||||
BashTool,
|
|
||||||
ReadTool,
|
|
||||||
GlobTool,
|
|
||||||
GrepTool,
|
|
||||||
EditTool,
|
|
||||||
WriteTool,
|
|
||||||
TaskTool,
|
|
||||||
WebFetchTool,
|
|
||||||
TodoWriteTool,
|
|
||||||
// TodoReadTool,
|
|
||||||
WebSearchTool,
|
|
||||||
CodeSearchTool,
|
|
||||||
SkillTool,
|
|
||||||
ApplyPatchTool,
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
|
||||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
|
||||||
...custom,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ids() {
|
export async function ids() {
|
||||||
return all().then((x) => x.map((t) => t.id))
|
return runPromise((svc) => svc.ids())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tools(
|
export async function tools(
|
||||||
@@ -136,39 +215,6 @@ export namespace ToolRegistry {
|
|||||||
},
|
},
|
||||||
agent?: Agent.Info,
|
agent?: Agent.Info,
|
||||||
) {
|
) {
|
||||||
const tools = await all()
|
return runPromise((svc) => svc.tools(model, agent))
|
||||||
const result = await Promise.all(
|
|
||||||
tools
|
|
||||||
.filter((t) => {
|
|
||||||
// Enable websearch/codesearch for zen users OR via enable flag
|
|
||||||
if (t.id === "codesearch" || t.id === "websearch") {
|
|
||||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
|
||||||
}
|
|
||||||
|
|
||||||
// use apply tool in same format as codex
|
|
||||||
const usePatch =
|
|
||||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
|
||||||
if (t.id === "apply_patch") return usePatch
|
|
||||||
if (t.id === "edit" || t.id === "write") return !usePatch
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(async (t) => {
|
|
||||||
using _ = log.time(t.id)
|
|
||||||
const tool = await t.init({ agent })
|
|
||||||
const output = {
|
|
||||||
description: tool.description,
|
|
||||||
parameters: tool.parameters,
|
|
||||||
}
|
|
||||||
await Plugin.trigger("tool.definition", { toolID: t.id }, output)
|
|
||||||
return {
|
|
||||||
id: t.id,
|
|
||||||
...tool,
|
|
||||||
description: output.description,
|
|
||||||
parameters: output.parameters,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { defer } from "@/util/defer"
|
import { defer } from "@/util/defer"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { PermissionNext } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
|
|
||||||
const parameters = z.object({
|
const parameters = z.object({
|
||||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||||
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
|||||||
// Filter agents by permissions if agent provided
|
// Filter agents by permissions if agent provided
|
||||||
const caller = ctx?.agent
|
const caller = ctx?.agent
|
||||||
const accessibleAgents = caller
|
const accessibleAgents = caller
|
||||||
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
|
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||||
: agents
|
: agents
|
||||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { MessageV2 } from "../session/message-v2"
|
import type { MessageV2 } from "../session/message-v2"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import type { PermissionNext } from "../permission"
|
import type { Permission } from "../permission"
|
||||||
import type { SessionID, MessageID } from "../session/schema"
|
import type { SessionID, MessageID } from "../session/schema"
|
||||||
import { Truncate } from "./truncate"
|
import { Truncate } from "./truncate"
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export namespace Tool {
|
|||||||
extra?: { [key: string]: any }
|
extra?: { [key: string]: any }
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
metadata(input: { title?: string; metadata?: M }): void
|
metadata(input: { title?: string; metadata?: M }): void
|
||||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { NodePath } from "@effect/platform-node"
|
|
||||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
|
||||||
import path from "path"
|
|
||||||
import type { Agent } from "../agent/agent"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
|
||||||
import { evaluate } from "@/permission/evaluate"
|
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { ToolID } from "./schema"
|
|
||||||
import { TRUNCATION_DIR } from "./truncation-dir"
|
|
||||||
|
|
||||||
export namespace Truncate {
|
|
||||||
const log = Log.create({ service: "truncation" })
|
|
||||||
const RETENTION = Duration.days(7)
|
|
||||||
|
|
||||||
export const MAX_LINES = 2000
|
|
||||||
export const MAX_BYTES = 50 * 1024
|
|
||||||
export const DIR = TRUNCATION_DIR
|
|
||||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
|
||||||
|
|
||||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
|
||||||
|
|
||||||
export interface Options {
|
|
||||||
maxLines?: number
|
|
||||||
maxBytes?: number
|
|
||||||
direction?: "head" | "tail"
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTaskTool(agent?: Agent.Info) {
|
|
||||||
if (!agent?.permission) return false
|
|
||||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
|
||||||
/**
|
|
||||||
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
|
||||||
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
|
||||||
*/
|
|
||||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* AppFileSystem.Service
|
|
||||||
|
|
||||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
|
||||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
|
||||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
|
||||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
|
||||||
Effect.catch(() => Effect.succeed([])),
|
|
||||||
)
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (Identifier.timestamp(entry) >= cutoff) continue
|
|
||||||
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
|
||||||
const maxLines = options.maxLines ?? MAX_LINES
|
|
||||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
|
||||||
const direction = options.direction ?? "head"
|
|
||||||
const lines = text.split("\n")
|
|
||||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
|
||||||
|
|
||||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
||||||
return { content: text, truncated: false } as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: string[] = []
|
|
||||||
let i = 0
|
|
||||||
let bytes = 0
|
|
||||||
let hitBytes = false
|
|
||||||
|
|
||||||
if (direction === "head") {
|
|
||||||
for (i = 0; i < lines.length && i < maxLines; i++) {
|
|
||||||
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > maxBytes) {
|
|
||||||
hitBytes = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out.push(lines[i])
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
|
||||||
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
|
||||||
if (bytes + size > maxBytes) {
|
|
||||||
hitBytes = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out.unshift(lines[i])
|
|
||||||
bytes += size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
|
||||||
const unit = hitBytes ? "bytes" : "lines"
|
|
||||||
const preview = out.join("\n")
|
|
||||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
|
||||||
|
|
||||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const hint = hasTaskTool(agent)
|
|
||||||
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
|
||||||
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
|
||||||
|
|
||||||
return {
|
|
||||||
content:
|
|
||||||
direction === "head"
|
|
||||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
|
||||||
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
|
||||||
truncated: true,
|
|
||||||
outputPath: file,
|
|
||||||
} as const
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* cleanup().pipe(
|
|
||||||
Effect.catchCause((cause) => {
|
|
||||||
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
|
||||||
return Effect.void
|
|
||||||
}),
|
|
||||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
||||||
Effect.delay(Duration.minutes(1)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ cleanup, output })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,144 @@
|
|||||||
|
import { NodePath } from "@effect/platform-node"
|
||||||
|
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||||
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { runtime } from "@/effect/runtime"
|
import { makeRunPromise } from "@/effect/run-service"
|
||||||
import { Truncate as S } from "./truncate-effect"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { evaluate } from "@/permission/evaluate"
|
||||||
|
import { Identifier } from "../id/id"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { ToolID } from "./schema"
|
||||||
|
import { TRUNCATION_DIR } from "./truncation-dir"
|
||||||
|
|
||||||
export namespace Truncate {
|
export namespace Truncate {
|
||||||
export const MAX_LINES = S.MAX_LINES
|
const log = Log.create({ service: "truncation" })
|
||||||
export const MAX_BYTES = S.MAX_BYTES
|
const RETENTION = Duration.days(7)
|
||||||
export const DIR = S.DIR
|
|
||||||
export const GLOB = S.GLOB
|
|
||||||
|
|
||||||
export type Result = S.Result
|
export const MAX_LINES = 2000
|
||||||
|
export const MAX_BYTES = 50 * 1024
|
||||||
|
export const DIR = TRUNCATION_DIR
|
||||||
|
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||||
|
|
||||||
export type Options = S.Options
|
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
maxLines?: number
|
||||||
|
maxBytes?: number
|
||||||
|
direction?: "head" | "tail"
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTaskTool(agent?: Agent.Info) {
|
||||||
|
if (!agent?.permission) return false
|
||||||
|
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
|
/**
|
||||||
|
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
||||||
|
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
||||||
|
*/
|
||||||
|
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
|
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||||
|
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||||
|
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||||
|
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||||
|
Effect.catch(() => Effect.succeed([])),
|
||||||
|
)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (Identifier.timestamp(entry) >= cutoff) continue
|
||||||
|
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||||
|
const maxLines = options.maxLines ?? MAX_LINES
|
||||||
|
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||||
|
const direction = options.direction ?? "head"
|
||||||
|
const lines = text.split("\n")
|
||||||
|
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||||
|
|
||||||
|
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||||
|
return { content: text, truncated: false } as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: string[] = []
|
||||||
|
let i = 0
|
||||||
|
let bytes = 0
|
||||||
|
let hitBytes = false
|
||||||
|
|
||||||
|
if (direction === "head") {
|
||||||
|
for (i = 0; i < lines.length && i < maxLines; i++) {
|
||||||
|
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > maxBytes) {
|
||||||
|
hitBytes = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.push(lines[i])
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
||||||
|
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
|
||||||
|
if (bytes + size > maxBytes) {
|
||||||
|
hitBytes = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.unshift(lines[i])
|
||||||
|
bytes += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||||
|
const unit = hitBytes ? "bytes" : "lines"
|
||||||
|
const preview = out.join("\n")
|
||||||
|
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
||||||
|
|
||||||
|
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
||||||
|
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const hint = hasTaskTool(agent)
|
||||||
|
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
||||||
|
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
||||||
|
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
direction === "head"
|
||||||
|
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
||||||
|
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
||||||
|
truncated: true,
|
||||||
|
outputPath: file,
|
||||||
|
} as const
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* cleanup().pipe(
|
||||||
|
Effect.catchCause((cause) => {
|
||||||
|
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
||||||
|
return Effect.void
|
||||||
|
}),
|
||||||
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||||
|
Effect.delay(Duration.minutes(1)),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Service.of({ cleanup, output })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
|
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
||||||
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
|
return runPromise((s) => s.output(text, options, agent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
|||||||
const items = ast.types.map(walk)
|
const items = ast.types.map(walk)
|
||||||
if (items.length === 1) return items[0]
|
if (items.length === 1) return items[0]
|
||||||
if (items.length < 2) return fail(ast)
|
if (items.length < 2) return fail(ast)
|
||||||
|
|
||||||
|
const discriminator = (ast as any).annotations?.discriminator
|
||||||
|
if (discriminator) {
|
||||||
|
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
|
||||||
|
}
|
||||||
|
|
||||||
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
|||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
import { AccountRepo } from "../../src/account/repo"
|
import { AccountRepo } from "../../src/account/repo"
|
||||||
import { Account } from "../../src/account/effect"
|
import { Account } from "../../src/account"
|
||||||
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
|
||||||
import { Database } from "../../src/storage/db"
|
import { Database } from "../../src/storage/db"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { test, expect } from "bun:test"
|
import { afterEach, test, expect } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { PermissionNext } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
|
|
||||||
// Helper to evaluate permission for a tool with wildcard pattern
|
// Helper to evaluate permission for a tool with wildcard pattern
|
||||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
|
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
|
||||||
if (!agent) return undefined
|
if (!agent) return undefined
|
||||||
return PermissionNext.evaluate(permission, "*", agent.permission).action
|
return Permission.evaluate(permission, "*", agent.permission).action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
test("returns default native agents when no config", async () => {
|
test("returns default native agents when no config", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
|
|||||||
// Wildcard is denied
|
// Wildcard is denied
|
||||||
expect(evalPerm(plan, "edit")).toBe("deny")
|
expect(evalPerm(plan, "edit")).toBe("deny")
|
||||||
// But specific path is allowed
|
// But specific path is allowed
|
||||||
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const explore = await Agent.get("explore")
|
const explore = await Agent.get("explore")
|
||||||
expect(explore).toBeDefined()
|
expect(explore).toBeDefined()
|
||||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
|
|||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
expect(build).toBeDefined()
|
expect(build).toBeDefined()
|
||||||
// Specific pattern is denied
|
// Specific pattern is denied
|
||||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||||
// Edit still allowed
|
// Edit still allowed
|
||||||
expect(evalPerm(build, "edit")).toBe("allow")
|
expect(evalPerm(build, "edit")).toBe("allow")
|
||||||
},
|
},
|
||||||
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||||
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
|
||||||
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -582,7 +586,7 @@ description: Permission skill.
|
|||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
|
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
|
||||||
const target = path.join(skillDir, "reference", "notes.md")
|
const target = path.join(skillDir, "reference", "notes.md")
|
||||||
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
|
|||||||
const originalToken = Account.token
|
const originalToken = Account.token
|
||||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||||
|
|
||||||
Account.active = mock(() => ({
|
Account.active = mock(async () => ({
|
||||||
id: AccountID.make("account-1"),
|
id: AccountID.make("account-1"),
|
||||||
email: "user@example.com",
|
email: "user@example.com",
|
||||||
url: "https://control.example.com",
|
url: "https://control.example.com",
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||||
|
import { InstanceState } from "../../src/effect/instance-state"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
|
||||||
|
return Instance.provide({
|
||||||
|
directory: dir,
|
||||||
|
fn: () => Effect.runPromise(InstanceState.get(state)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState caches values per directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
|
||||||
|
|
||||||
|
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
|
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
|
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(n).toBe(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState isolates directories", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
|
||||||
|
|
||||||
|
const a = yield* Effect.promise(() => access(state, one.path))
|
||||||
|
const b = yield* Effect.promise(() => access(state, two.path))
|
||||||
|
const c = yield* Effect.promise(() => access(state, one.path))
|
||||||
|
|
||||||
|
expect(a).toBe(c)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(n).toBe(2)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState invalidates on reload", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const seen: string[] = []
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make(() =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() => ({ n: ++n })),
|
||||||
|
(value) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
seen.push(String(value.n))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const a = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
|
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
|
||||||
|
const b = yield* Effect.promise(() => access(state, tmp.path))
|
||||||
|
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
expect(seen).toEqual(["1"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState invalidates on disposeAll", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
const seen: string[] = []
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.sync(() => ({ dir: ctx.directory })),
|
||||||
|
(value) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
seen.push(value.dir)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => access(state, one.path))
|
||||||
|
yield* Effect.promise(() => access(state, two.path))
|
||||||
|
yield* Effect.promise(() => Instance.disposeAll())
|
||||||
|
|
||||||
|
expect(seen.sort()).toEqual([one.path, two.path].sort())
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState.get reads the current directory lazily", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: () => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||||
|
const get = InstanceState.get(state)
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* () {
|
||||||
|
return yield* get
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const a = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
})
|
||||||
|
const b = await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(a).toBe(one.path)
|
||||||
|
expect(b).toBe(two.path)
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState preserves directory across async boundaries", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
await using three = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) =>
|
||||||
|
Effect.sync(() => ({
|
||||||
|
directory: ctx.directory,
|
||||||
|
worktree: ctx.worktree,
|
||||||
|
project: ctx.project.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* () {
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
yield* Effect.sleep(Duration.millis(1))
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
yield* Effect.promise(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
yield* Effect.sleep(Duration.millis(2))
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
}),
|
||||||
|
Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
}),
|
||||||
|
Instance.provide({
|
||||||
|
directory: three.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
|
||||||
|
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
|
||||||
|
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
|
||||||
|
expect(a.project).not.toBe(b.project)
|
||||||
|
expect(a.project).not.toBe(c.project)
|
||||||
|
expect(b.project).not.toBe(c.project)
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState survives high-contention concurrent access", async () => {
|
||||||
|
const N = 20
|
||||||
|
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: () => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* () {
|
||||||
|
// Interleave many async hops to maximize chance of ALS corruption
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
yield* Effect.promise(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
dirs.map((d) =>
|
||||||
|
Instance.provide({
|
||||||
|
directory: d.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
expect(results[i]).toBe(dirs[i].path)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
for (const d of dirs) await d[Symbol.asyncDispose]()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState correct after interleaved init and dispose", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: () => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await Bun.sleep(5) // slow init
|
||||||
|
return ctx.directory
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* () {
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Init both directories
|
||||||
|
const a = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
})
|
||||||
|
expect(a).toBe(one.path)
|
||||||
|
|
||||||
|
// Dispose one directory, access the other concurrently
|
||||||
|
const [, b] = await Promise.all([
|
||||||
|
Instance.reload({ directory: one.path }),
|
||||||
|
Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(b).toBe(two.path)
|
||||||
|
|
||||||
|
// Re-access disposed directory - should get fresh state
|
||||||
|
const c = await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
|
||||||
|
})
|
||||||
|
expect(c).toBe(one.path)
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState mutation in one directory does not leak to another", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
|
||||||
|
|
||||||
|
// Mutate state in directory one
|
||||||
|
const s1 = yield* Effect.promise(() => access(state, one.path))
|
||||||
|
s1.count = 42
|
||||||
|
|
||||||
|
// Access directory two — should be independent
|
||||||
|
const s2 = yield* Effect.promise(() => access(state, two.path))
|
||||||
|
expect(s2.count).toBe(0)
|
||||||
|
|
||||||
|
// Confirm directory one still has the mutation
|
||||||
|
const s1again = yield* Effect.promise(() => access(state, one.path))
|
||||||
|
expect(s1again.count).toBe(42)
|
||||||
|
expect(s1again).toBe(s1) // same reference
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState dedupes concurrent lookups", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make(() =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
n += 1
|
||||||
|
await Bun.sleep(10)
|
||||||
|
return { n }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(n).toBe(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { makeRunPromise } from "../../src/effect/run-service"
|
||||||
|
|
||||||
|
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
|
||||||
|
|
||||||
|
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
|
||||||
|
let n = 0
|
||||||
|
|
||||||
|
const shared = Layer.effect(
|
||||||
|
Shared,
|
||||||
|
Effect.sync(() => {
|
||||||
|
n += 1
|
||||||
|
return Shared.of({ id: n })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
|
||||||
|
const one = Layer.effect(
|
||||||
|
One,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Shared
|
||||||
|
return One.of({
|
||||||
|
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(shared))
|
||||||
|
|
||||||
|
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
|
||||||
|
const two = Layer.effect(
|
||||||
|
Two,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Shared
|
||||||
|
return Two.of({
|
||||||
|
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(shared))
|
||||||
|
|
||||||
|
const runOne = makeRunPromise(One, one)
|
||||||
|
const runTwo = makeRunPromise(Two, two)
|
||||||
|
|
||||||
|
expect(await runOne((svc) => svc.get())).toBe(1)
|
||||||
|
expect(await runTwo((svc) => svc.get())).toBe(1)
|
||||||
|
expect(n).toBe(1)
|
||||||
|
})
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
|
|
||||||
import { Auth } from "../../src/auth/effect"
|
|
||||||
import { Instances } from "../../src/effect/instances"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
|
||||||
import { ProviderAuth } from "../../src/provider/auth"
|
|
||||||
import { Vcs } from "../../src/project/vcs"
|
|
||||||
import { Question } from "../../src/question"
|
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for the Effect runtime and LayerMap-based instance system.
|
|
||||||
*
|
|
||||||
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
|
|
||||||
* so it is always rebuilt per directory, while shared dependencies are provided
|
|
||||||
* outside the fresh boundary and remain memoizable.
|
|
||||||
*
|
|
||||||
* These tests verify the invariants using object identity (===) on the real
|
|
||||||
* production services — not mock services or return-value checks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
|
|
||||||
|
|
||||||
describe("effect/runtime", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
await Instance.disposeAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("global services are shared across directories", async () => {
|
|
||||||
await using one = await tmpdir({ git: true })
|
|
||||||
await using two = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
// Auth is a global service — it should be the exact same object
|
|
||||||
// regardless of which directory we're in.
|
|
||||||
const authOne = await Instance.provide({
|
|
||||||
directory: one.path,
|
|
||||||
fn: () => grabGlobal(Auth.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
const authTwo = await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: () => grabGlobal(Auth.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(authOne).toBe(authTwo)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
|
|
||||||
await using one = await tmpdir({ git: true })
|
|
||||||
await using two = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
// ProviderAuth depends on Auth via defaultLayer.
|
|
||||||
// The instance service itself should be different per directory,
|
|
||||||
// but the underlying Auth should be shared.
|
|
||||||
const paOne = await Instance.provide({
|
|
||||||
directory: one.path,
|
|
||||||
fn: () => grabInstance(ProviderAuth.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
const paTwo = await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: () => grabInstance(ProviderAuth.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Different directories → different ProviderAuth instances.
|
|
||||||
expect(paOne).not.toBe(paTwo)
|
|
||||||
|
|
||||||
// But the global Auth is the same object in both.
|
|
||||||
const authOne = await Instance.provide({
|
|
||||||
directory: one.path,
|
|
||||||
fn: () => grabGlobal(Auth.Service),
|
|
||||||
})
|
|
||||||
const authTwo = await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: () => grabGlobal(Auth.Service),
|
|
||||||
})
|
|
||||||
expect(authOne).toBe(authTwo)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("instance services are shared within the same directory", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
|
|
||||||
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("different directories get different service instances", async () => {
|
|
||||||
await using one = await tmpdir({ git: true })
|
|
||||||
await using two = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
const vcsOne = await Instance.provide({
|
|
||||||
directory: one.path,
|
|
||||||
fn: () => grabInstance(Vcs.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
const vcsTwo = await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: () => grabInstance(Vcs.Service),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(vcsOne).not.toBe(vcsTwo)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("disposal rebuilds services with a new instance", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const before = await grabInstance(Question.Service)
|
|
||||||
|
|
||||||
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
|
|
||||||
|
|
||||||
const after = await grabInstance(Question.Service)
|
|
||||||
expect(after).not.toBe(before)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { afterEach, describe, test, expect } from "bun:test"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
@@ -7,6 +7,10 @@ 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"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
describe("file/index Filesystem patterns", () => {
|
describe("file/index Filesystem patterns", () => {
|
||||||
describe("File.read() - text content", () => {
|
describe("File.read() - text content", () => {
|
||||||
test("reads text file via Filesystem.readText()", async () => {
|
test("reads text file via Filesystem.readText()", async () => {
|
||||||
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search works before explicit init", async () => {
|
||||||
|
await using tmp = await setupSearchableRepo()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.search({ query: "main", type: "file" })
|
||||||
|
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("empty query returns dirs sorted with hidden last", async () => {
|
test("empty query returns dirs sorted with hidden last", async () => {
|
||||||
await using tmp = await setupSearchableRepo()
|
await using tmp = await setupSearchableRepo()
|
||||||
|
|
||||||
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search refreshes after init when files change", async () => {
|
||||||
|
await using tmp = await setupSearchableRepo()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await File.init()
|
||||||
|
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
|
||||||
|
|
||||||
|
const result = await File.search({ query: "fresh", type: "file" })
|
||||||
|
expect(result).toContain("fresh.ts")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("File.read() - diff/patch", () => {
|
describe("File.read() - diff/patch", () => {
|
||||||
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("InstanceState isolation", () => {
|
||||||
|
test("two directories get independent file caches", async () => {
|
||||||
|
await using one = await tmpdir({ git: true })
|
||||||
|
await using two = await tmpdir({ git: true })
|
||||||
|
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
|
||||||
|
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: async () => {
|
||||||
|
await File.init()
|
||||||
|
const results = await File.search({ query: "a.ts", type: "file" })
|
||||||
|
expect(results).toContain("a.ts")
|
||||||
|
const results2 = await File.search({ query: "b.ts", type: "file" })
|
||||||
|
expect(results2).not.toContain("b.ts")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: async () => {
|
||||||
|
await File.init()
|
||||||
|
const results = await File.search({ query: "b.ts", type: "file" })
|
||||||
|
expect(results).toContain("b.ts")
|
||||||
|
const results2 = await File.search({ query: "a.ts", type: "file" })
|
||||||
|
expect(results2).not.toContain("a.ts")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disposal gives fresh state on next access", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await File.init()
|
||||||
|
const results = await File.search({ query: "before", type: "file" })
|
||||||
|
expect(results).toContain("before.ts")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.disposeAll()
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
|
||||||
|
await fs.rm(path.join(tmp.path, "before.ts"))
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await File.init()
|
||||||
|
const results = await File.search({ query: "after", type: "file" })
|
||||||
|
expect(results).toContain("after.ts")
|
||||||
|
const stale = await File.search({ query: "before", type: "file" })
|
||||||
|
expect(stale).not.toContain("before.ts")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
|
|||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
async function touch(file: string, time: number) {
|
async function touch(file: string, time: number) {
|
||||||
const date = new Date(time)
|
const date = new Date(time)
|
||||||
@@ -84,6 +86,28 @@ describe("file/time", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("isolates reads by directory", async () => {
|
||||||
|
await using one = await tmpdir()
|
||||||
|
await using two = await tmpdir()
|
||||||
|
await using shared = await tmpdir()
|
||||||
|
const filepath = path.join(shared.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: one.path,
|
||||||
|
fn: async () => {
|
||||||
|
await FileTime.read(sessionID, filepath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: two.path,
|
||||||
|
fn: async () => {
|
||||||
|
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("assert()", () => {
|
describe("assert()", () => {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import path from "path"
|
|||||||
import { Deferred, Effect, Option } from "effect"
|
import { Deferred, Effect, Option } from "effect"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
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"
|
|
||||||
|
|
||||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||||
@@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
|
||||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||||
|
|
||||||
/** Run `body` with a live FileWatcher service. */
|
/** Run `body` with a live FileWatcher service. */
|
||||||
@@ -25,7 +24,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
directory,
|
directory,
|
||||||
FileWatcher.layer,
|
FileWatcher.layer,
|
||||||
async (rt) => {
|
async (rt) => {
|
||||||
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
|
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
|
||||||
await Effect.runPromise(ready(directory))
|
await Effect.runPromise(ready(directory))
|
||||||
await Effect.runPromise(body)
|
await Effect.runPromise(body)
|
||||||
},
|
},
|
||||||
@@ -36,22 +35,17 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
|||||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
function on(evt: BusUpdate) {
|
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||||
if (done) return
|
if (done) return
|
||||||
if (evt.directory !== directory) return
|
if (!check(evt.properties)) return
|
||||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
hit(evt.properties)
|
||||||
if (!check(evt.payload.properties)) return
|
})
|
||||||
hit(evt.payload.properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
return () => {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
GlobalBus.off("event", on)
|
unsub()
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalBus.on("event", on)
|
|
||||||
return cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||||
@@ -136,7 +130,9 @@ function ready(directory: string) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describeWatcher("FileWatcher", () => {
|
describeWatcher("FileWatcher", () => {
|
||||||
afterEach(() => Instance.disposeAll())
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
test("publishes root create, update, and delete events", async () => {
|
test("publishes root create, update, and delete events", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user