Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 5ac2e0505b refactor(core): remove unused small model selection 2026-08-05 14:04:47 -04:00
138 changed files with 6207 additions and 3538 deletions
-40
View File
@@ -690,10 +690,8 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -1525,18 +1523,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1745,10 +1731,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1843,8 +1825,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -3331,8 +3311,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3573,10 +3551,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4609,8 +4583,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4837,8 +4809,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4877,10 +4847,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5679,8 +5645,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6641,8 +6605,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6787,8 +6749,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
+5
View File
@@ -248,6 +248,11 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
return IS_MAC ? parts.join("") : parts.join("+")
}
// KeybindV2 takes an array instead of a string
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
return formatKeybindParts(config, t)
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
+7
View File
@@ -286,6 +286,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
children: tree.children,
expand: tree.expandDir,
collapse: tree.collapseDir,
toggle(input: string) {
if (tree.dirState(input)?.expanded) {
tree.collapseDir(input)
return
}
tree.expandDir(input)
},
},
get,
load,
@@ -153,6 +153,18 @@ export function normalizeProviderList(
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
}
}
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
return {
...project,
+30
View File
@@ -753,6 +753,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
@@ -958,6 +961,33 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (current.reviewOpen.includes(path)) return
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
},
closePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current) return
const index = current.indexOf(path)
if (index === -1) return
setStore(
"sessionView",
session,
"reviewOpen",
produce((draft) => {
if (!draft) return
draft.splice(index, 1)
}),
)
},
togglePath(path: string) {
const session = key()
const current = store.sessionView[session]?.reviewOpen
if (!current || !current.includes(path)) {
this.openPath(path)
return
}
this.closePath(path)
},
},
}
},
@@ -22,6 +22,8 @@ type TabsInput = {
fileBrowser?: Accessor<boolean>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
return input.opened && input.visible
}
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory },
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
+2 -1
View File
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory },
location: { directory: input.location.directory, workspace: input.location.workspaceID },
})
.catch(() => undefined),
])
@@ -757,6 +757,7 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
+1 -1
View File
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
next.model ??
(options.variant
? await client.model
.default({ location: { directory: next.location.directory } })
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected
+1
View File
@@ -136,6 +136,7 @@ async function latestSession(
const page = await client.session.list(
{
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
limit: SESSION_PAGE_LIMIT,
order: "desc",
+2 -1
View File
@@ -426,13 +426,14 @@ describe("runNonInteractivePrompt", () => {
const globalOptions = {
headers: {
"x-opencode-directory": "%2Fwork%20tree",
"x-opencode-workspace": "wrk_1",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
location: { directory: "/work tree", workspace: "wrk_1" },
})
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
+104 -59
View File
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
import type { Location } from "@opencode-ai/schema/location"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Plugin } from "@opencode-ai/schema/plugin"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { Session } from "@opencode-ai/schema/session"
import type { AbsolutePath } from "@opencode-ai/schema/schema"
import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
@@ -58,7 +58,9 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E>
}
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint2_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint2_0Output = Location.Info
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
@@ -66,13 +68,15 @@ export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
}
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint3_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
export type Endpoint3_1Input = {
readonly agentID: Agent.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
@@ -82,7 +86,9 @@ export interface AgentApi<E = never> {
readonly get: AgentGetOperation<E>
}
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint4_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
@@ -91,6 +97,7 @@ export interface PluginApi<E = never> {
}
export type Endpoint5_0Input = {
readonly workspace?: Workspace.ID | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -891,11 +898,15 @@ export interface MessageApi<E = never> {
readonly list: MessageListOperation<E>
}
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint7_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint7_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
@@ -905,7 +916,7 @@ export interface ModelApi<E = never> {
}
export type Endpoint8_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string
readonly model?: Model.Ref | undefined
}
@@ -916,13 +927,15 @@ export interface GenerateApi<E = never> {
readonly text: GenerateTextOperation<E>
}
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint9_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
export type Endpoint9_1Input = {
readonly providerID: Provider.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
@@ -932,19 +945,21 @@ export interface ProviderApi<E = never> {
readonly get: ProviderGetOperation<E>
}
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint10_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
export type Endpoint10_1Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export type Endpoint10_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly url: string
}
export type Endpoint10_2Output = void
@@ -954,7 +969,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
export type Endpoint10_3Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly key: string
readonly label?: string | undefined
}
@@ -965,7 +980,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
export type Endpoint10_4Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID
readonly inputs: { readonly [x: string]: string }
readonly label?: string | undefined
@@ -978,7 +993,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
export type Endpoint10_5Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
export type IntegrationOauthStatusOperation<E = never> = (
@@ -988,7 +1003,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
export type Endpoint10_6Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly code?: string | undefined
}
export type Endpoint10_6Output = void
@@ -999,7 +1014,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
export type Endpoint10_7Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_7Output = void
export type IntegrationOauthCancelOperation<E = never> = (
@@ -1008,7 +1023,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
export type Endpoint10_8Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID
readonly label?: string | undefined
}
@@ -1020,7 +1035,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
export type Endpoint10_9Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
export type IntegrationCommandStatusOperation<E = never> = (
@@ -1030,7 +1045,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
export type Endpoint10_10Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_10Output = void
export type IntegrationCommandCancelOperation<E = never> = (
@@ -1055,13 +1070,15 @@ export interface IntegrationApi<E = never> {
}
}
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint11_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
export type Endpoint11_1Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
}
export type Endpoint11_1Output = void
@@ -1069,26 +1086,28 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
export type Endpoint11_2Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_2Output = void
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
export type Endpoint11_3Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_3Output = void
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export type Endpoint11_4Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_4Output = void
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint11_5Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
@@ -1103,7 +1122,7 @@ export interface McpApi<E = never> {
export type Endpoint12_0Input = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly label: string
}
export type Endpoint12_0Output = void
@@ -1111,7 +1130,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
export type Endpoint12_1Input = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint12_1Output = void
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
@@ -1124,13 +1143,15 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint13_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
@@ -1141,7 +1162,9 @@ export interface ProjectApi<E = never> {
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint14_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
@@ -1185,7 +1208,9 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E>
}
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint15_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
export type PermissionRequestListOperation<E = never> = (
input?: Endpoint15_0Input,
@@ -1243,14 +1268,14 @@ export interface PermissionApi<E = never> {
}
export type Endpoint16_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: RelativePath | undefined
}
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
export type Endpoint16_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
@@ -1263,7 +1288,9 @@ export interface FileApi<E = never> {
readonly find: FileFindOperation<E>
}
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint17_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
@@ -1271,7 +1298,9 @@ export interface CommandApi<E = never> {
readonly list: CommandListOperation<E>
}
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint18_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
@@ -1286,12 +1315,14 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
}
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint20_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
export type Endpoint20_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command?: string | undefined
readonly args?: ReadonlyArray<string> | undefined
readonly cwd?: string | undefined
@@ -1303,14 +1334,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
export type Endpoint20_2Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_3Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly title?: string | undefined
readonly size?: { readonly rows: number; readonly cols: number } | undefined
}
@@ -1319,7 +1350,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
export type Endpoint20_4Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_4Output = void
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
@@ -1332,12 +1363,14 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E>
}
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
@@ -1348,14 +1381,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
export type Endpoint21_2Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number
}
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
@@ -1363,7 +1396,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
export type Endpoint21_4Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}
@@ -1380,7 +1413,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
export type Endpoint21_5Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
@@ -1394,7 +1427,9 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input,
@@ -1423,7 +1458,9 @@ export interface QuestionApi<E = never> {
readonly reject: QuestionRejectOperation<E>
}
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
@@ -1433,7 +1470,7 @@ export interface ReferenceApi<E = never> {
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
@@ -1443,7 +1480,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
@@ -1452,7 +1489,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
@@ -1463,16 +1500,20 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E>
}
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
@@ -1488,7 +1529,9 @@ export interface VcsApi<E = never> {
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
@@ -1496,12 +1539,14 @@ export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
}
export type Endpoint27_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint27_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint27_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
@@ -285,6 +285,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
preserveEffect<Endpoint5_0Output>()(
raw["session.list"]({
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
@@ -441,6 +441,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session`,
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
+196 -67
View File
@@ -2514,7 +2514,9 @@ export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type LocationGetOutput = {
@@ -2524,7 +2526,9 @@ export type LocationGetOutput = {
}
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentListOutput = {
@@ -2534,7 +2538,9 @@ export type AgentListOutput = {
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentGetOutput = {
@@ -2543,7 +2549,9 @@ export type AgentGetOutput = {
}
export type PluginListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PluginListOutput = {
@@ -2552,7 +2560,19 @@ export type PluginListOutput = {
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly parentID?: string | null | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2563,6 +2583,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2573,6 +2594,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2583,6 +2605,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["search"]
readonly parentID?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2593,6 +2616,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["parentID"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2603,6 +2627,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2613,6 +2638,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2623,6 +2649,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -3217,7 +3244,9 @@ export type MessageListInput = {
export type MessageListOutput = SessionMessagesResponse
export type ModelListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelListOutput = {
@@ -3226,7 +3255,9 @@ export type ModelListOutput = {
}
export type ModelDefaultInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelDefaultOutput = {
@@ -3235,7 +3266,9 @@ export type ModelDefaultOutput = {
}
export type GenerateTextInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -3249,7 +3282,9 @@ export type GenerateTextInput = {
export type GenerateTextOutput = GenerateTextResponse["data"]
export type ProviderListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProviderListOutput = {
@@ -3259,7 +3294,9 @@ export type ProviderListOutput = {
export type ProviderGetInput = {
readonly providerID: { readonly providerID: string }["providerID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProviderGetOutput = {
@@ -3268,7 +3305,9 @@ export type ProviderGetOutput = {
}
export type IntegrationListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationListOutput = {
@@ -3278,7 +3317,9 @@ export type IntegrationListOutput = {
export type IntegrationGetInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationGetOutput = {
@@ -3287,7 +3328,9 @@ export type IntegrationGetOutput = {
}
export type IntegrationWellknownAddInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly url: { readonly url: string }["url"]
}
@@ -3295,7 +3338,9 @@ export type IntegrationWellknownAddOutput = void
export type IntegrationConnectKeyInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
}
@@ -3304,7 +3349,9 @@ export type IntegrationConnectKeyOutput = void
export type IntegrationOauthConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: {
readonly methodID: string
readonly inputs: { readonly [x: string]: string }
@@ -3336,7 +3383,9 @@ export type IntegrationOauthConnectOutput = {
export type IntegrationOauthStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationOauthStatusOutput = {
@@ -3347,7 +3396,9 @@ export type IntegrationOauthStatusOutput = {
export type IntegrationOauthCompleteInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly code?: { readonly code?: string | undefined }["code"]
}
@@ -3356,14 +3407,18 @@ export type IntegrationOauthCompleteOutput = void
export type IntegrationOauthCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationOauthCancelOutput = void
export type IntegrationCommandConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
}
@@ -3376,7 +3431,9 @@ export type IntegrationCommandConnectOutput = {
export type IntegrationCommandStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationCommandStatusOutput = {
@@ -3387,13 +3444,17 @@ export type IntegrationCommandStatusOutput = {
export type IntegrationCommandCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationCommandCancelOutput = void
export type McpListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpListOutput = {
@@ -3403,7 +3464,9 @@ export type McpListOutput = {
export type McpAddInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly config: {
readonly config:
| {
@@ -3452,27 +3515,35 @@ export type McpAddOutput = void
export type McpRemoveInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpRemoveOutput = void
export type McpConnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpConnectOutput = void
export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpDisconnectOutput = void
export type McpResourceCatalogInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpResourceCatalogOutput = {
@@ -3482,7 +3553,9 @@ export type McpResourceCatalogOutput = {
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly label: { readonly label: string }["label"]
}
@@ -3490,7 +3563,9 @@ export type CredentialUpdateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialRemoveOutput = void
@@ -3498,20 +3573,26 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectCurrentInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type FormRequestListOutput = {
@@ -4365,7 +4446,9 @@ export type FormCancelInput = {
export type FormCancelOutput = void
export type PermissionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionRequestListOutput = {
@@ -4471,7 +4554,9 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type FileReadInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly path: string
}
@@ -4479,11 +4564,11 @@ export type FileReadOutput = globalThis.Uint8Array
export type FileListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
}["location"]
readonly path?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
}["path"]
}
@@ -4495,25 +4580,25 @@ export type FileListOutput = {
export type FileFindInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["location"]
readonly query: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["query"]
readonly type?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["type"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
@@ -4526,7 +4611,9 @@ export type FileFindOutput = {
}
export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CommandListOutput = {
@@ -4535,7 +4622,9 @@ export type CommandListOutput = {
}
export type SkillListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type SkillListOutput = {
@@ -4546,7 +4635,9 @@ export type SkillListOutput = {
export type EventSubscribeOutput = V2Event
export type PtyListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyListOutput = {
@@ -4555,7 +4646,9 @@ export type PtyListOutput = {
}
export type PtyCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command?: {
readonly command?: string
readonly args?: ReadonlyArray<string>
@@ -4600,7 +4693,9 @@ export type PtyCreateOutput = {
export type PtyGetInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyGetOutput = {
@@ -4610,7 +4705,9 @@ export type PtyGetOutput = {
export type PtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly title?: {
readonly title?: string
readonly size?: { readonly rows: number; readonly cols: number }
@@ -4625,13 +4722,17 @@ export type PtyUpdateOutput = {
export type PtyRemoveInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyRemoveOutput = void
export type ShellListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellListOutput = {
@@ -4640,7 +4741,9 @@ export type ShellListOutput = {
}
export type ShellCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command: {
readonly command: string
readonly cwd?: string
@@ -4674,7 +4777,9 @@ export type ShellCreateOutput = {
export type ShellGetInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellGetOutput = {
@@ -4684,7 +4789,9 @@ export type ShellGetOutput = {
export type ShellTimeoutInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly timeout: { readonly timeout: number }["timeout"]
}
@@ -4696,17 +4803,17 @@ export type ShellTimeoutOutput = {
export type ShellOutputInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["location"]
readonly cursor?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["cursor"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["limit"]
@@ -4719,13 +4826,17 @@ export type ShellOutputOutput = {
export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellRemoveOutput = void
export type QuestionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionRequestListOutput = {
@@ -4753,7 +4864,9 @@ export type QuestionRejectInput = {
export type QuestionRejectOutput = void
export type ReferenceListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ReferenceListOutput = {
@@ -4763,7 +4876,9 @@ export type ReferenceListOutput = {
export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
@@ -4773,7 +4888,9 @@ export type ProjectCopyCreateOutput = ProjectCopyCopy
export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
@@ -4782,13 +4899,17 @@ export type ProjectCopyRemoveOutput = void
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
@@ -4797,7 +4918,9 @@ export type VcsGetOutput = {
}
export type VcsStatusInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsStatusOutput = {
@@ -4807,17 +4930,17 @@ export type VcsStatusOutput = {
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["location"]
readonly mode: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["mode"]
readonly context?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["context"]
@@ -4831,13 +4954,17 @@ export type VcsDiffOutput = {
export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type DebugLocationEvictOutput = void
export type WebsearchProvidersInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProvidersOutput = {
@@ -4846,7 +4973,9 @@ export type WebsearchProvidersOutput = {
}
export type WebsearchQueryInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
}
@@ -58,3 +58,13 @@ export const setMethods = new Set([
"isSupersetOf",
"isDisjointFrom",
])
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
if (Array.isArray(value)) return value
if (typeof value === "string") return Array.from(value)
if (value instanceof CodeModeMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
if (value instanceof CodeModeSet) return Array.from(value.set.values())
if (value instanceof CodeModeURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
return undefined
}
import { CodeModeMap, CodeModeSet, CodeModeURLSearchParams } from "../values.js"
+2 -1
View File
@@ -1021,12 +1021,13 @@ describe("OpenAPI.fromSpec", () => {
await Effect.runPromise(
location
.execute({ location: { directory: "/tmp" } })
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
})
test("serializes supported simple and form parameter shapes", async () => {
@@ -0,0 +1,103 @@
[data-component="desktop-promo"] {
--promo-background: hsl(0, 20%, 99%);
--promo-background-weak: hsl(0, 8%, 97%);
--promo-text: hsl(0, 1%, 39%);
--promo-text-strong: hsl(0, 5%, 12%);
--promo-border: hsla(0, 100%, 3%, 0.12);
position: fixed;
z-index: 20;
right: 1.5rem;
bottom: 1.5rem;
width: min(28rem, calc(100vw - 2rem));
padding: 4px;
overflow: hidden;
color: var(--promo-text);
border: 1px solid var(--promo-border);
border-radius: 8px;
background: var(--promo-background);
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 15%);
font-family: var(--font-mono);
@media (prefers-color-scheme: dark) {
--promo-background: hsl(0, 9%, 7%);
--promo-background-weak: hsl(0, 6%, 10%);
--promo-text: hsl(0, 4%, 71%);
--promo-text-strong: hsl(0, 15%, 94%);
--promo-border: hsl(0, 4%, 23%);
}
@media (max-width: 40rem) {
right: 1rem;
bottom: 1rem;
}
[data-slot="desktop-promo-link"] {
display: block;
color: var(--promo-text);
text-decoration: none;
}
[data-slot="desktop-promo-link"]:focus-visible {
outline: 2px solid var(--promo-text-strong);
outline-offset: -3px;
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 4px;
background: var(--promo-background-weak);
}
[data-slot="desktop-promo-copy"] {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem;
font-size: 0.875rem;
line-height: 1.4;
}
[data-slot="desktop-promo-copy"] strong {
color: var(--promo-text-strong);
font-weight: 500;
}
[data-slot="desktop-promo-close"] {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: grid;
width: 2rem;
height: 2rem;
padding: 0;
place-items: center;
cursor: pointer;
color: white;
border: none;
border-radius: 0.25rem;
background: rgb(0 0 0 / 70%);
opacity: 0;
transition:
opacity 150ms ease,
background 150ms ease;
}
&:hover [data-slot="desktop-promo-close"],
[data-slot="desktop-promo-close"]:focus-visible {
opacity: 1;
}
[data-slot="desktop-promo-close"]:hover {
background: rgb(0 0 0 / 90%);
}
@media (hover: none) {
[data-slot="desktop-promo-close"] {
opacity: 1;
}
}
}
@@ -0,0 +1,60 @@
import "./desktop-promo.css"
import { A, useLocation } from "@solidjs/router"
import { createSignal, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import desktopPromoVideo from "~/asset/lander/desktop-tabs-landscape.mp4"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { strip } from "~/lib/language"
const DISMISSED_COOKIE = "desktop_promo_dismissed"
export function DesktopPromo() {
const i18n = useI18n()
const language = useLanguage()
const location = useLocation()
const request = getRequestEvent()?.request
const cookie = request?.headers.get("cookie") ?? (typeof document === "object" ? document.cookie : "")
const [visible, setVisible] = createSignal(
!cookie.split(";").some((value) => value.trim() === `${DISMISSED_COOKIE}=1`),
)
const hostname = request ? new URL(request.url).hostname : typeof window === "object" ? window.location.hostname : ""
const primaryHost =
hostname === "opencode.ai" || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
return (
<Show
when={
visible() &&
primaryHost &&
strip(location.pathname) !== "/download" &&
!strip(location.pathname).startsWith("/download/")
}
>
<aside data-component="desktop-promo">
<A href={language.route("/download")} data-slot="desktop-promo-link">
<video src={desktopPromoVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
<span data-slot="desktop-promo-copy">
<strong>{i18n.t("home.promo.title")}</strong>
<span>
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
</span>
</span>
</A>
<button
type="button"
data-slot="desktop-promo-close"
onClick={() => {
document.cookie = `${DISMISSED_COOKIE}=1; Path=/; Max-Age=31536000; SameSite=Lax`
setVisible(false)
}}
>
<span class="sr-only">{i18n.t("home.promo.close")}</span>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
</aside>
</Show>
)
}
@@ -19,6 +19,10 @@ export function Span({ children, ...props }: SpanProps) {
return React.createElement("span", props, children)
}
export function Wbr({ children, ...props }: WbrProps) {
return React.createElement("wbr", props, children)
}
export function Fonts({ assetsUrl }: { assetsUrl: string }) {
return (
<>
@@ -55,3 +59,14 @@ export function Fonts({ assetsUrl }: { assetsUrl: string }) {
</>
)
}
export function SplitString({ text, split }: { text: string; split: number }) {
const segments: JSX.Element[] = []
for (let i = 0; i < text.length; i += split) {
segments.push(<>{text.slice(i, i + split)}</>)
if (i + split < text.length) {
segments.push(<Wbr key={`${i}wbr`} />)
}
}
return <>{segments}</>
}
+105 -70
View File
@@ -1,11 +1,15 @@
{
"version": "7",
"dialect": "sqlite",
"id": "70de00ff-f8ce-47f6-a1db-5213168971ae",
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
"prevIds": [
"6307bc18-2612-47e2-acfc-2a6a68ff28c5"
"db37a97f-9b5e-4c87-be8b-4feace35136c"
],
"ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "data_migration",
"entityType": "tables"
@@ -87,8 +91,84 @@
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "''",
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "branch",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extra",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_used",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
@@ -1511,63 +1591,18 @@
"table": "session_share"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "binding",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "root",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_workspace_project_id_project_id_fk",
"entityType": "fks",
"table": "workspace"
},
{
@@ -1780,6 +1815,15 @@
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"name"
@@ -1933,15 +1977,6 @@
"table": "session_share",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
{
+4
View File
@@ -44,6 +44,7 @@ export type Draft = {
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly default: () => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
readonly list: () => Effect.Effect<Info[]>
@@ -109,6 +110,9 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
default: Effect.fn("Agent.default")(function* () {
return selectedDefault()
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
-56
View File
@@ -52,7 +52,6 @@ export interface Interface extends State.Transformable<Draft> {
readonly all: () => Effect.Effect<Model.Info[]>
readonly available: () => Effect.Effect<Model.Info[]>
readonly default: () => Effect.Effect<Model.Info | undefined>
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
}
}
@@ -206,59 +205,6 @@ const layer = Layer.effect(
return (yield* result.model.available())[0]
}),
small: Effect.fn("Catalog.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return
const provider = record.provider
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
return
}
if (providerID === Provider.ID.opencode) {
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
const candidates = pipe(
Array.fromIterable(record.models.values()),
Array.filter(
(model) =>
model.providerID === providerID &&
model.enabled &&
model.status === "active" &&
model.capabilities.input.some((item) => item.startsWith("text")) &&
model.capabilities.output.some((item) => item.startsWith("text")),
),
Array.map((model) => ({
model,
cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),
small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
})),
Array.filter((item) => item.cost > 0 && item.age <= 18),
)
const pick = (items: typeof candidates) => {
if (!Array.isReadonlyArrayNonEmpty(items)) return
const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
const maxAge = Math.max(...items.map((item) => item.age), 0.01)
const selected = Array.min(
items,
Order.mapInput(
Order.Number,
(item: (typeof candidates)[number]) =>
(item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2,
),
)
return projectModel(selected.model, provider)
}
const small = candidates.filter((item) => item.small)
return pick(small.length > 0 ? small : candidates)
}),
},
}
@@ -266,6 +212,4 @@ const layer = Layer.effect(
}),
)
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
@@ -0,0 +1,20 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/sql"
import { Project } from "../project"
import { Workspace } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<Project.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})
-2
View File
@@ -59,7 +59,5 @@ export const migrations = (
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
import("./migration/20260730195856_optional_session_title"),
import("./migration/20260805225117_remove_workspace"),
import("./migration/20260805232152_add_workspace"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,11 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260805225117_remove_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,20 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260805232152_add_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
+13 -10
View File
@@ -4,6 +4,19 @@ import type { DatabaseMigration } from "./migration"
export default {
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`type\` text NOT NULL,
\`name\` text DEFAULT '' NOT NULL,
\`branch\` text,
\`directory\` text,
\`extra\` text,
\`project_id\` text NOT NULL,
\`time_used\` integer NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`data_migration\` (
\`name\` text PRIMARY KEY,
@@ -241,16 +254,6 @@ export default {
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+7 -56
View File
@@ -5,7 +5,6 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target {
readonly canonical: string
@@ -37,13 +36,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
/**
* Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -59,6 +51,13 @@ const layer = Layer.effect(
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.canonical,
resource: target.resource,
existed,
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
@@ -91,54 +90,6 @@ const layer = Layer.effect(
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Same cooperative locking, writes through WorkspaceEnvironment.Files. The
// environment write reports prior existence, so no stat pre-check round trip.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const encoder = new TextEncoder()
const locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
const bytes = (content: string | Uint8Array) => (typeof content === "string" ? encoder.encode(content) : content)
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
env.files
.write(input.target.canonical, bytes(input.content))
.pipe(
Effect.orDie,
Effect.map((result) => writeResult(input.target, result.existed)),
),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* WorkspaceEnvironment.optional(env.files.read(input.target.canonical))
const text = Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)
const result = yield* env.files.write(input.target.canonical, bytes(text)).pipe(Effect.orDie)
return writeResult(input.target, result.existed)
}),
),
)
return Service.of({ write, writeTextPreservingBom })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/**
* Deferred until the corresponding integrations exist.
*/
+5 -61
View File
@@ -7,8 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
import { WorkspaceEnvironment } from "./workspace/environment"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -54,6 +53,8 @@ export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
@@ -75,6 +76,8 @@ const baseLayer = Layer.effect(
})
return Service.of({
find: search.find,
glob: search.glob,
grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
@@ -116,62 +119,3 @@ export const node = makeLocationNode({
layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
// Mirrors baseLayer over WorkspaceEnvironment.Files with posix path rules.
// Host filesystem services never see provider paths. Type mismatches surface
// from the environment operation itself; no stat pre-checks.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.posix.resolve(location.directory, input ?? ".")
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* env.files.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.containsPosix(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
return Service.of({
// Graceful like the hosted tool-catalog filter: completion surfaces keep
// working with empty results instead of defecting.
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
return {
content: yield* env.files.read(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
return yield* env.files.list(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.posix.join(target.absolute, item.name)
const relative = path.posix.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? "/" : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node, Location.node],
})
+38
View File
@@ -3,6 +3,9 @@ import {
type DirItem,
type DirSearchResult,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type InitOptions,
type MixedItem,
type MixedSearchResult,
@@ -42,6 +45,19 @@ export interface MixedSearch {
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
@@ -55,6 +71,14 @@ export interface Picker {
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
@@ -71,6 +95,18 @@ export interface Picker {
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
@@ -91,8 +127,10 @@ export function create(opts: Init): Result<Picker> {
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
+38
View File
@@ -2,6 +2,9 @@ import type {
DirItem,
DirSearchResult,
FileItem,
GrepCursor,
GrepMatch,
GrepResult,
InitOptions,
MixedItem,
MixedSearchResult,
@@ -39,6 +42,19 @@ export interface MixedSearch {
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
@@ -52,6 +68,14 @@ export interface Picker {
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
@@ -68,6 +92,18 @@ export interface Picker {
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
@@ -89,8 +125,10 @@ export function create(opts: Init): Result<Picker> {
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
+19
View File
@@ -1,3 +1,5 @@
import { Glob } from "@opencode-ai/util/glob"
const FOLDERS = new Set([
"node_modules",
"bower_components",
@@ -45,4 +47,21 @@ const FILES = [
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(/[/\\]/)
for (const part of parts) {
if (FOLDERS.has(part)) return true
}
for (const pattern of [...FILES, ...(opts?.extra || [])]) {
if (Glob.match(pattern, filepath)) return true
}
return false
}
export * as Ignore from "./ignore"
@@ -3,6 +3,7 @@ export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
@@ -43,7 +44,7 @@ const layer = Layer.effect(
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (!home && location.vcs) {
const updates = yield* watcher.subscribe({
@@ -3,10 +3,6 @@ import path from "path"
const home = os.homedir()
export function isHome(directory: string) {
return path.resolve(directory) === path.resolve(home)
}
const DARWIN_HOME = [
"Music",
"Pictures",
+111 -15
View File
@@ -6,13 +6,15 @@ import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Protected } from "./protected"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
}
export const Options = Schema.Struct({
@@ -25,18 +27,17 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
files.push(entry.path)
@@ -46,6 +47,57 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
glob: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
)
}),
grep: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
.pipe(
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
),
Effect.orDie,
)
}),
find: (input) =>
Effect.gen(function* () {
const items =
@@ -87,10 +139,55 @@ export const fffLayer = Layer.effect(
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
return Service.of({
find: () => Effect.succeed([]),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
})
}
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
})
if (!found.ok) throw found.error
return found.value.items.map((item) =>
FileSystem.Entry.make({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
}),
)
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
}),
line: match.lineNumber,
offset: match.byteOffset,
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
})
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
@@ -135,19 +232,18 @@ export const fffLayer = Layer.effect(
}),
)
export const layer = (options?: Options) =>
Layer.unwrap(
Effect.gen(function* () {
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
}),
)
export const layer = (options?: Options) => Layer.unwrap(
Effect.gen(function* () {
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
return ripgrepLayer
const location = yield* Location.Service
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
return location.vcs ? fffLayer : ripgrepLayer
}),
)
export function configured(options?: Options) {
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
}
export const node = configured()
+1
View File
@@ -10,6 +10,7 @@ const prefixes = {
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function ascending(prefix: keyof typeof prefixes, given?: string) {
+1 -64
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
@@ -26,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
reason: Schema.Literal("non_directory_ancestor"),
}) {}
export interface ExternalDirectoryAuthorization {
@@ -156,65 +155,3 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
// rules. Hosted paths are never external: everything outside the Location is
// rejected instead of routed to external_directory approval, because the
// approval boundary vocabulary is host-relative.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
const existing = yield* WorkspaceEnvironment.optional(env.files.realPath(absolute))
if (existing !== undefined) {
const info = yield* WorkspaceEnvironment.optional(env.files.stat(existing))
if (info === undefined) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : path.posix.dirname(existing),
} satisfies ResolvedPath
}
let anchor = path.posix.dirname(absolute)
while (true) {
const canonical = yield* WorkspaceEnvironment.optional(env.files.realPath(anchor))
if (canonical !== undefined) {
const info = yield* WorkspaceEnvironment.optional(env.files.stat(canonical))
if (info === undefined || info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: path.posix.resolve(canonical, path.posix.relative(anchor, absolute)),
directory: canonical,
} satisfies ResolvedPath
}
const parent = path.posix.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.posix.resolve(location.directory, input.path)
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
const resolved = yield* resolvePath(absolute)
return {
canonical: resolved.canonical,
resource: path.posix.relative(location.directory, absolute) || ".",
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer.pipe(Layer.orDie),
deps: [WorkspaceEnvironment.node, Location.node],
})
+1 -20
View File
@@ -47,7 +47,6 @@ import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { Tool } from "./tool"
import { Vcs } from "./vcs"
import { WorkspaceEnvironment } from "./workspace/environment"
export { LocationServiceMap } from "./location-service-map"
@@ -119,25 +118,7 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const workspaceID = ref.workspaceID
// Hosted graphs state their Location (no host discovery), read only
// global config sources, and bind the workspace environment; local
// graphs are byte-identical to before.
const allReplacements = replacements.concat(
workspaceID
? [
[Location.node, Location.hostedBoundNode(ref, workspaceID)],
[Config.node, Config.configured({ project: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false })],
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[Shell.node, Shell.hostedNode],
[PluginSupervisor.node, PluginSupervisor.hostedNode],
]
: [[Location.node, Location.boundNode(ref)]],
)
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
-25
View File
@@ -1,8 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Project } from "./project"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
@@ -39,26 +37,3 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref),
deps: [Project.node],
})
/**
* Hosted Locations state their Project instead of discovering it: host git
* and filesystem walks must never run against a provider directory. The
* Workspace root comes from the already-connected environment, avoiding a
* second workspace row read per graph build.
*/
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return Service.of({
directory: ref.directory,
workspaceID,
project: Project.hostedGlobal(env.directory),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+19 -6
View File
@@ -12,6 +12,7 @@ import { Credential } from "../credential"
import { Bus } from "../bus"
import { Form } from "../form"
import { Integration } from "../integration"
import { IntegrationConnection } from "../integration/connection"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Location } from "../location"
import { waitForAbort } from "@opencode-ai/util/process"
@@ -30,6 +31,7 @@ export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
name: ServerName,
status: Status,
integrationID: Integration.ID.pipe(Schema.optional),
connection: IntegrationConnection.Info.pipe(Schema.optional),
}) {}
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
@@ -245,6 +247,14 @@ export const layer = (options?: Options) => Layer.effect(
return { name, entry }
})
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
new ServerInfo({
name,
status: entry.status,
integrationID: entry.integrationID,
connection,
})
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
@@ -593,12 +603,15 @@ export const layer = (options?: Options) => Layer.effect(
)
return Service.of({
servers: Effect.fn("MCP.servers")(function* () {
return Array.from(runtime)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(
([name, entry]) =>
new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }),
)
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
return yield* Effect.forEach(entries, ([name, entry]) =>
Effect.gen(function* () {
const connection = entry.integrationID
? yield* integration.connection.active(entry.integrationID)
: undefined
return info(name, entry, connection)
}),
)
}),
add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server)
+2 -16
View File
@@ -1,8 +1,7 @@
export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Option, Scope } from "effect"
import { WorkspaceEnvironment } from "../workspace/environment"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent"
import { Catalog } from "../catalog"
@@ -96,14 +95,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const wellknown = yield* WellKnown.Service
// Bound only in hosted Location graphs; plugins read it with serviceOption
// to route filesystem checks at the provider instead of the host.
const environment = yield* Effect.serviceOption(WorkspaceEnvironment.Service)
return Context.mergeAll(
Option.match(environment, {
onSome: (value) => Context.make(WorkspaceEnvironment.Service, value),
onNone: () => Context.empty(),
}),
Context.make(Agent.Service, agent),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
@@ -179,13 +171,7 @@ const post = [
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
// Not advertised for hosted Locations until their execution is
// environment-backed: glob/grep spawn the host ripgrep binary against
// provider paths. Hosted glob/grep later spawn rg inside the workspace image.
const hostedExcluded: ReadonlySet<string> = new Set([GlobTool.Plugin.id, GrepTool.Plugin.id])
export const list = Effect.fn("PluginInternal.list")(function* () {
const location = yield* Location.Service
const context = yield* services()
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
@@ -195,7 +181,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
}),
)
return {
pre: resolve(location.workspaceID ? pre.filter((plugin) => !hostedExcluded.has(plugin.id)) : pre),
pre: resolve(pre),
post: resolve(post),
}
})
+37 -49
View File
@@ -40,7 +40,6 @@ import { ReadToolFileSystem } from "../tool/read-filesystem"
import { Tool } from "../tool"
import { WebSearch } from "../websearch"
import { WellKnown } from "../wellknown"
import { WorkspaceEnvironment } from "../workspace/environment"
import { PluginInternal } from "./internal"
import { PluginRuntime } from "./runtime"
import { SdkPlugins } from "./sdk"
@@ -283,9 +282,7 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -311,54 +308,45 @@ const layer = Layer.effect(
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
const nodeDeps = [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
] as const
export const node = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: nodeDeps,
})
/** Hosted graphs bind the workspace environment so internal plugins can reach it. */
export const hostedNode = makeLocationNode({
service: Service,
layer: nodeLayer,
deps: [...nodeDeps, WorkspaceEnvironment.node],
deps: [
Plugin.node,
SdkPlugins.node,
Agent.node,
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
FSUtil.node,
Global.node,
httpClient,
Image.node,
Integration.node,
KV.node,
Location.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
PluginRuntime.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
Tool.node,
Watcher.node,
WebSearch.node,
WellKnown.node,
],
})
export { layer }
+15 -13
View File
@@ -44,18 +44,6 @@ export interface Resolved {
readonly vcs?: Vcs
}
/**
* The stated Project for hosted Locations: global identity until rediscovery
* inside the Workspace finds a repository. Canonical "/" matches the local
* non-VCS fallback on posix hosts, so persisting it never repoints the global
* row at a provider path.
*/
export const hostedGlobal = (root: string): Resolved => ({
id: ID.global,
directory: AbsolutePath.make(root),
canonical: AbsolutePath.make("/"),
})
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
@@ -71,6 +59,16 @@ export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
*
* This exists while the old opencode project service and this core project
* service work together: core resolves the ID, while the old service still owns
* database migration and persistence. The old service should call this after it
* finishes migrating from `resolve().previous` to `resolve().id`; once project
* persistence moves into core, this separate bridge method can go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -270,7 +268,11 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, directories, resolve })
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
})
return Service.of({ list, directories, resolve, commit })
}),
)
+32 -12
View File
@@ -70,6 +70,11 @@ export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUn
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"ProjectCopy.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
@@ -94,6 +99,7 @@ export interface Strategy {
export { Event }
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
@@ -138,18 +144,29 @@ const layer = Layer.effect(
return resolved
})
const strategy = makeGitWorktreeStrategy({ git, canonical })
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
return strategy
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
@@ -209,18 +226,20 @@ const layer = Layer.effect(
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
@@ -252,6 +271,7 @@ const layer = Layer.effect(
})
return Service.of({
register,
create,
remove,
refresh,
+21
View File
@@ -41,6 +41,7 @@ export interface Interface {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
@@ -98,6 +99,25 @@ const layer = Layer.effect(
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const contains = Effect.fn("ProjectDirectories.contains")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
return (
(yield* db
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
@@ -119,6 +139,7 @@ const layer = Layer.effect(
return Service.of({
list,
get,
contains,
create,
remove,
})
+5 -1
View File
@@ -1,5 +1,6 @@
export * as PtyTicket from "./ticket"
import { Workspace } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect"
@@ -13,6 +14,7 @@ export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: Workspace.ID
}
export interface Interface {
@@ -23,7 +25,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return record.ptyID === input.ptyID && record.directory === input.directory
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
+17
View File
@@ -31,6 +31,14 @@ export type EnsureInput = {
readonly branch?: string
}
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
"RepositoryCacheInvalidRepositoryError",
{
repository: Schema.String,
message: Schema.String,
},
) {}
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
"RepositoryCacheInvalidBranchError",
{
@@ -78,6 +86,7 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
) {}
export type Error =
| InvalidRepositoryError
| InvalidBranchError
| CloneFailedError
| FetchFailedError
@@ -94,6 +103,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Re
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidRepositoryError ||
error instanceof InvalidBranchError ||
error instanceof CloneFailedError ||
error instanceof FetchFailedError ||
@@ -104,6 +114,13 @@ export function isError(error: unknown): error is Error {
)
}
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
return yield* Effect.try({
try: () => Repository.parseRemote(repository),
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
})
})
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
return yield* Effect.try({
try: () => Repository.validateBranch(branch),
+10
View File
@@ -44,6 +44,16 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
message: Schema.String,
}) {}
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
export function isError(error: unknown): error is Error {
return (
error instanceof InvalidReferenceError ||
error instanceof UnsupportedLocalRepositoryError ||
error instanceof InvalidBranchError
)
}
export function parse(input: string): Reference | undefined {
const cleaned = normalizeInput(input)
if (!cleaned) return
-2
View File
@@ -52,7 +52,6 @@ export interface FindInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly exclude?: readonly string[]
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
@@ -196,7 +195,6 @@ const layer = Layer.effect(
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
+44 -27
View File
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
@@ -27,8 +28,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error"
import { Workspace } from "./workspace"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event"
@@ -52,6 +52,9 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
export const RevertState = Session.Revert
export type RevertState = Session.Revert
// get project -> project.locations
//
// get all sessions
@@ -59,9 +62,12 @@ import { fileURLToPath } from "url"
// - by project
// - by subpath
// - by workspace (home is special)
export { ListAnchor }
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
@@ -104,6 +110,13 @@ type ForkInput = {
boundary: Session.ForkRequestBoundary
}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]),
},
) {}
export { MessageDecodeError, NotFoundError }
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
@@ -147,13 +160,28 @@ export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<Destin
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error =
| NotFoundError
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| SyntheticConflictError
| AttachmentError
| CompactionConflictError
| BusyError
| SkillNotFoundError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Command.NotFoundError
| Command.EvaluationError
| MessageNotFoundError
| SessionGenerate.Error
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
readonly create: (
input: CreateInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -205,6 +233,7 @@ export interface Interface {
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
@@ -285,7 +314,6 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
@@ -297,17 +325,6 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
// Host Project discovery must not run against provider directories; hosted
// Sessions state the global Project until rediscovery inside the Workspace.
const hostedProject = Effect.fn("Session.hostedProject")(function* (
workspaceID: Workspace.ID,
directory: AbsolutePath,
) {
const workspace = yield* workspaces.get(workspaceID)
if (!FSUtil.containsPosix(workspace.root, directory))
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
return Project.hostedGlobal(workspace.root)
})
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
@@ -345,9 +362,7 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = location.workspaceID
? yield* hostedProject(location.workspaceID, location.directory)
: yield* projects.resolve(location.directory)
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
@@ -355,12 +370,10 @@ const layer = Layer.effect(
slug: Slug.create(),
version: app.version,
projectID: project.id,
workspaceID: location.workspaceID,
parentID: input.parentID,
directory: location.directory,
path: location.workspaceID
? path.posix.relative(project.directory, location.directory)
: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
title: input.title,
agent: input.agent,
model: input.model
@@ -448,6 +461,7 @@ const layer = Layer.effect(
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
@@ -725,7 +739,11 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
if (
current.location.directory === directory &&
current.location.workspaceID === input.workspaceID
)
return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
@@ -734,7 +752,7 @@ const layer = Layer.effect(
}
yield* bus.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
})
@@ -1037,7 +1055,6 @@ export const node = makeGlobalNode({
Database.node,
Bus.node,
Project.node,
Workspace.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
-14
View File
@@ -2,7 +2,6 @@ export * as SessionErrors from "./error"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -19,19 +18,6 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
}
}
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
"Session.WorkspaceDirectoryError",
{
workspaceID: WorkspaceID,
directory: Schema.String,
root: Schema.String,
},
) {
override get message() {
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
}
}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+120 -8
View File
@@ -1,8 +1,12 @@
import { castDraft, produce, type WritableDraft } from "immer"
import { DateTime, Effect, Match, pipe } from "effect"
import { DateTime, Effect } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Info[]
}
export interface Adapter {
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
@@ -19,7 +23,89 @@ export interface Adapter {
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
}
export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
export function memory(state: MemoryState): Adapter {
const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
return {
getModel() {
return Effect.sync(
() =>
state.messages.findLast(
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
message.type === "model-switched" || message.type === "assistant",
)?.model,
)
},
getCurrentAssistant() {
return Effect.sync(() => {
const index = latestAssistantIndex()
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
})
},
getAssistant(messageID) {
return Effect.sync(() => {
const index = assistantIndex(messageID)
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" ? assistant : undefined
})
},
getShell(shellID) {
return Effect.sync(() => {
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shellID === shellID
})
})
},
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) {
return Effect.sync(() => {
const index = assistantIndex(assistant.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "assistant") return
state.messages[index] = assistant
})
},
updateShell(shell) {
return Effect.sync(() => {
const index = shellIndex(shell.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
state.messages[index] = shell
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) {
return Effect.sync(() => {
state.messages.push(message)
})
},
}
}
export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText>
@@ -53,9 +139,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}
})
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
@@ -235,6 +321,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
if (match) match.text += event.data.delta
})
},
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft)
@@ -259,6 +351,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
@@ -282,6 +375,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}
})
},
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
if (match && match.state.status === "running") {
match.state.metadata = event.data.metadata
}
})
},
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
@@ -335,6 +436,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
},
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
if (match) match.text += event.data.delta
})
},
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft)
@@ -368,6 +475,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
time: { created: event.created },
}),
),
"session.compaction.delta": (event) =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (current?.status !== "running") return
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
}),
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = yield* adapter.getCompaction()
@@ -413,9 +526,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
}),
)
return project(event)
})
})
}
export * as SessionMessageUpdater from "./message-updater"
+11 -1
View File
@@ -8,9 +8,11 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
import { InstructionState } from "./instruction-state"
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@@ -427,6 +429,14 @@ const layer = Layer.effectDiscard(
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
if (event.data.info.workspaceID) {
yield* db
.update(WorkspaceTable)
.set({ time_used: Date.now() })
.where(eq(WorkspaceTable.id, event.data.info.workspaceID))
.run()
.pipe(Effect.orDie)
}
}),
)
yield* bus.project(SessionV1.Event.Updated, (event) =>
@@ -445,7 +455,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory,
path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: null,
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
+14 -56
View File
@@ -12,7 +12,6 @@ import { Bus } from "./bus"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
import { WorkspaceEnvironment } from "./workspace/environment"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks"
@@ -66,24 +65,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */
interface Backend {
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
/** Shell executable; re-evaluated per command so config changes apply. */
readonly shell: Effect.Effect<string>
readonly args: (shell: string, command: string) => readonly string[]
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
readonly env: Readonly<Record<string, string | undefined>>
readonly detached: boolean
}
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const spawner = yield* backend
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
@@ -152,7 +141,12 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
return session.info
})
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name))
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
@@ -192,9 +186,9 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* spawner.shell,
shell: yield* resolve(),
env: {
...spawner.env,
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
@@ -203,7 +197,7 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = spawner.args(invocation.shell, invocation.command)
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
@@ -224,12 +218,12 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: spawner.detached,
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
@@ -345,23 +339,6 @@ const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => Layer.effect(
}),
)
export const layer = (options?: ShellSelect.Options) =>
layerWith(
Effect.gen(function* () {
const config = yield* Config.Service
const appProcess = yield* AppProcess.Service
return {
spawn: (command) => appProcess.spawn(command),
shell: config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
args: ShellSelect.args,
env: process.env,
detached: process.platform !== "win32",
} satisfies Backend
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
@@ -371,22 +348,3 @@ export function configured(options?: ShellSelect.Options) {
}
export const node = configured()
// Commands run inside the workspace: provider spawner, image shell lowering,
// and no host process.env leakage. Output capture files remain host-owned.
export const hostedNode = makeLocationNode({
service: Service,
layer: layerWith(
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return {
spawn: (command) => env.process.spawn(command),
shell: Effect.succeed(env.shell.executable),
args: (_shell, command) => env.shell.args(command),
env: env.shell.environmentOverrides,
detached: env.shell.detached,
} satisfies Backend
}),
),
deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node],
})
+10 -6
View File
@@ -7,16 +7,16 @@ import { Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { which } from "../util/which"
const META: Record<string, { deny?: boolean; login?: boolean; ps?: boolean }> = {
bash: { login: true },
dash: { login: true },
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true },
zsh: { login: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
}
export type Item = {
@@ -116,6 +116,10 @@ export function login(file: string) {
return meta(file)?.login === true
}
export function posix(file: string) {
return meta(file)?.posix === true
}
export function ps(file: string) {
return meta(file)?.ps === true
}
+42 -84
View File
@@ -4,7 +4,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, FileSystem, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
@@ -13,7 +13,6 @@ import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import { WorkspaceEnvironment } from "../../workspace/environment"
import DESCRIPTION from "../patch.txt"
export const name = "patch"
@@ -67,33 +66,6 @@ interface Target {
}
}
type BackendError = PlatformError | globalThis.Error | WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError
/** The four verbs patch needs, provided by the host filesystem or the workspace environment. */
interface Backend {
readonly read: (path: string) => Effect.Effect<{ readonly bom: boolean; readonly text: string }, BackendError>
readonly stat: (path: string) => Effect.Effect<{ readonly type: FileSystem.File.Type }, BackendError>
readonly write: (path: string, content: string) => Effect.Effect<void, BackendError>
readonly remove: (path: string) => Effect.Effect<void, BackendError>
}
const hostBackend = (fs: FSUtil.Interface): Backend => ({
read: (target) => Bom.readFile(fs, target),
stat: (target) => fs.stat(target),
write: (target, content) => fs.writeWithDirs(target, content),
remove: (target) => fs.remove(target),
})
const environmentBackend = (environment: WorkspaceEnvironment.Interface): Backend => {
const encoder = new TextEncoder()
return {
read: (target) => Effect.map(environment.files.read(target), Bom.fromBytes),
stat: (target) => environment.files.stat(target),
write: (target, content) => Effect.asVoid(environment.files.write(target, encoder.encode(content))),
remove: (target) => environment.files.remove(target),
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
@@ -101,11 +73,6 @@ export const Plugin = {
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
// Hosted Locations bind the workspace environment: file verbs run at the
// provider, paths resolve as posix, and host formatters never run.
const environment = Option.getOrUndefined(yield* Effect.serviceOption(WorkspaceEnvironment.Service))
const hosted = environment !== undefined
const backend = environment ? environmentBackend(environment) : hostBackend(fs)
yield* ctx.tool
.transform((draft) =>
@@ -144,7 +111,7 @@ export const Plugin = {
const updates = new Map<string, string>()
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path, hosted)
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
@@ -174,7 +141,7 @@ export const Plugin = {
return
}
if (hunk.type === "delete") {
const content = yield* backend.read(target.canonical).pipe(
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -189,7 +156,7 @@ export const Plugin = {
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* backend.stat(target.canonical).pipe(
const stats = yield* fs.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -202,7 +169,7 @@ export const Plugin = {
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
const content = yield* backend.read(target.canonical).pipe(
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -218,7 +185,7 @@ export const Plugin = {
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath, hosted) : undefined
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
@@ -272,8 +239,8 @@ export const Plugin = {
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* backend
.write(
yield* fs
.writeWithDirs(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
@@ -290,7 +257,7 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* backend
yield* fs
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
@@ -304,10 +271,10 @@ export const Plugin = {
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* backend
.write(moveTarget.canonical, change.content)
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* backend.remove(change.target.canonical).pipe(
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
@@ -319,8 +286,8 @@ export const Plugin = {
})
return
}
yield* backend
.write(change.target.canonical, change.content)
yield* fs
.writeWithDirs(change.target.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -330,29 +297,25 @@ export const Plugin = {
}),
{ discard: true },
)
// Hosted Locations skip formatting: formatters are host
// binaries and cannot run against provider paths. patchFile
// then falls back to the in-memory after-content.
const formatted = new Map<string, string>()
if (!hosted)
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
yield* Effect.forEach(
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
)
}),
{ discard: true },
)
const files = yield* Effect.forEach(prepared, (change) => {
if (change.type === "delete") return Effect.succeed(patchFile(change))
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
@@ -393,7 +356,6 @@ export const Plugin = {
}
function errorMessage(error: unknown) {
if (error instanceof WorkspaceEnvironment.NotFoundError) return "file does not exist"
if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message
@@ -453,27 +415,23 @@ function trimDiff(diff: string) {
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string, hosted: boolean): Target {
// Hosted paths live in the provider filesystem: always posix, regardless of
// the host platform.
const paths = hosted ? path.posix : path
const contains = hosted ? FSUtil.containsPosix : FSUtil.contains
function resolveTarget(location: Location.Interface, value: string): Target {
const canonical =
!hosted && process.platform === "win32"
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: paths.resolve(location.directory, value)
const projectRoot = paths.parse(location.project.directory).root
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !contains(location.project.directory, canonical))
const directory = paths.dirname(canonical)
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
!hosted && process.platform === "win32"
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: paths.join(directory, "*").replaceAll("\\", "/")
: path.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: paths.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+6 -18
View File
@@ -4,9 +4,8 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Scope } from "effect"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { WorkspaceEnvironment } from "../../workspace/environment"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -84,21 +83,6 @@ export const Plugin = {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
// Hosted Locations bind the workspace environment; the workdir check must
// stat the provider filesystem there, never the host's.
const environment = Option.getOrUndefined(yield* Effect.serviceOption(WorkspaceEnvironment.Service))
const statWorkdir = (canonical: string) =>
environment
? environment.files.stat(canonical).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () =>
Effect.fail(new Error(`Working directory does not exist: ${canonical}`)),
),
)
: fsUtil.stat(canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${canonical}`)),
),
)
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -192,7 +176,11 @@ export const Plugin = {
agent: context.agent,
source,
})
const workdir = yield* statWorkdir(target.canonical)
const workdir = yield* fsUtil.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.canonical}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
-71
View File
@@ -1,77 +1,6 @@
export * as Workspace from "./workspace"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "./database/database"
import { WorkspaceDriver } from "./workspace/driver"
import { WorkspaceTable } from "./workspace/sql"
export const ID = Workspace.ID
export type ID = typeof ID.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
provider: Schema.String,
root: AbsolutePath,
}).annotate({ identifier: "Workspace.Info" })
export interface Interface {
/** Allocate through the named driver and persist. Resolves when the Workspace is usable. */
readonly create: (input: {
readonly provider: string
}) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFoundError>
/** Metadata read; never contacts a driver. */
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
/** Provider key and opaque binding for WorkspaceDriver.connect. Hosted Location construction only. */
readonly binding: (id: ID) => Effect.Effect<
{ readonly provider: string; readonly binding: WorkspaceDriver.Binding },
NotFoundError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const registry = yield* WorkspaceDriver.RegistryService
const require = Effect.fn("Workspace.require")(function* (id: ID) {
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ id })
return row
})
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const driver = yield* registry.get(input.provider)
const id = ID.create()
const created = yield* driver.create({ workspaceID: id })
yield* db
.insert(WorkspaceTable)
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
.pipe(Effect.orDie)
return Info.make({ id, provider: input.provider, root: AbsolutePath.make(created.root) })
}),
get: Effect.fn("Workspace.get")(function* (id) {
const row = yield* require(id)
return Info.make({ id: row.id, provider: row.provider, root: AbsolutePath.make(row.root) })
}),
binding: Effect.fn("Workspace.binding")(function* (id) {
const row = yield* require(id)
return { provider: row.provider, binding: row.binding }
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, WorkspaceDriver.registryNode] })
-70
View File
@@ -1,70 +0,0 @@
export * as WorkspaceDriver from "./driver"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
/**
* Smallest provider-owned JSON value required to reconnect to the same
* provider resource. Core stores it opaquely and hands it back; only the
* owning driver reads inside.
*/
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
provider: Schema.String,
message: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Interface {
/** Allocate a new environment. Resolves only when it is ready to use. */
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding; readonly root: string }, Error>
/**
* Binding -> live capabilities; the only way to obtain an environment.
* Fresh-create and restart-reconnect both flow through here. Closing the
* scope drops the connection, never the provider resource.
*/
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
/** Permanently release provider resources. */
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
}
/** Identity constructor so driver definitions get contextual typing. */
export const make = (driver: Interface) => driver
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
/** Immutable registry fixed at composition time. */
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = drivers[provider]
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFoundError({ provider }))
},
})
/**
* Defaults to no drivers, so every provider fails with ProviderNotFoundError.
* Server composition (or a test) replaces this binding with real drivers;
* core never constructs one.
*/
export const registryNode = makeGlobalNode({
service: RegistryService,
layer: Layer.succeed(RegistryService, RegistryService.of(registry({}))),
deps: [],
})
-108
View File
@@ -1,108 +0,0 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Workspace } from "../workspace"
import { WorkspaceDriver } from "./driver"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
/** Distinct so callers (e.g. the LocationMutation ancestor walk) can catch absence. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceEnvironment.NotFoundError", {
path: Schema.String,
}) {}
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
/**
* The minimal primitive set the hosted implementations of Filesystem,
* LocationMutation, and FileMutation consume. Grow it only when a real
* consumer appears. Every method is one provider round trip: type mismatches
* (read a directory, list a file) fail with Error instead of requiring a
* stat pre-check.
*/
export interface Files {
readonly stat: (path: string) => Effect.Effect<FileInfo, Error | NotFoundError>
/** Canonical path with symlinks resolved; identity for permissions and locking. */
readonly realPath: (path: string) => Effect.Effect<string, Error | NotFoundError>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error | NotFoundError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error | NotFoundError>
/** Creates parent directories, matching FSUtil.writeWithDirs. Reports whether the file already existed. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<{ readonly existed: boolean }, Error>
/** Removes one file. Removing a missing path fails with NotFoundError. */
readonly remove: (path: string) => Effect.Effect<void, Error | NotFoundError>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
/** The Workspace root, absolute in the provider filesystem. */
readonly directory: string
readonly files: Files
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
/** NotFound becomes undefined; other environment failures become defects. */
export const optional = <A>(effect: Effect.Effect<A, Error | NotFoundError>): Effect.Effect<A | undefined> =>
effect.pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
/** Present only in hosted Location graphs; local graphs never bind it. */
export const node = LayerNode.unbound(Service, tags.values.location)
/**
* Connects on graph boot and stays connected for the graph's cached lifetime:
* Layer.effect supplies the build scope, so releasing the LayerMap entry
* closes the connection. It never stops or deletes the provider resource.
*/
export const hostedNode = (workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
// Connect failures during graph build are defects, matching the local
// graph's E = never. Typed availability errors are a later design.
layer: Layer.effect(
Service,
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const registry = yield* WorkspaceDriver.RegistryService
const found = yield* workspaces.binding(workspaceID)
const driver = yield* registry.get(found.provider)
return yield* driver.connect(found.binding)
}),
).pipe(Layer.orDie),
deps: [Workspace.node, WorkspaceDriver.registryNode],
})
/** Identity constructor so environment literals get contextual typing. */
export const make = (environment: Interface) => environment
/** Default lowering for Linux sandbox images. */
export const linuxShell: Shell = {
executable: "/bin/bash",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
}
-12
View File
@@ -1,12 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { WorkspaceDriver } from "./driver"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
root: text().notNull(),
...Timestamps,
})
-43
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -292,46 +291,4 @@ describe("Catalog", () => {
})
}),
)
it.effect("small model prefers small keyword candidates before cost scoring", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("test")
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, Model.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(1),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
catalog.model.update(providerID, Model.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [
{
input: Money.USDPerMillionTokens.make(10),
output: Money.USDPerMillionTokens.make(10),
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
]
model.time.released = Date.now()
})
})
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
})
@@ -967,6 +967,9 @@ describe("DatabaseMigration", () => {
yield* db.run(
sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`,
)
yield* db.run(
sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`,
)
yield* db.run(
sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`,
)
@@ -991,10 +994,6 @@ describe("DatabaseMigration", () => {
// must drop before the historical rename dance and recreate after.
yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
// The V2 workspace table now occupies the name; recreate the legacy
// shape this historical migration ran against.
yield* db.run(sql`DROP TABLE workspace`)
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
yield* db.run(sql`DROP TABLE session_context_epoch`)
@@ -1030,6 +1029,7 @@ describe("DatabaseMigration", () => {
(SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID,
(SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
(SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
(SELECT COUNT(*) FROM workspace) AS workspaces,
(SELECT COUNT(*) FROM session_pending) AS sessionInputs,
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
(SELECT COUNT(*) FROM instruction_state) AS instructionStates,
@@ -1041,6 +1041,7 @@ describe("DatabaseMigration", () => {
workspaceID: null,
messages: 1,
parts: 1,
workspaces: 0,
sessionInputs: 0,
sessionMessages: 0,
instructionStates: 0,
@@ -80,6 +80,7 @@ describe("node build", () => {
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
commit: () => Effect.void,
})
}),
)
@@ -3,6 +3,14 @@ import { Ignore } from "@opencode-ai/core/filesystem/ignore"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
test("match nested and non-nested", () => {
expect(Ignore.match("node_modules/index.js")).toBe(true)
expect(Ignore.match("node_modules")).toBe(true)
expect(Ignore.match("node_modules/")).toBe(true)
expect(Ignore.match("node_modules/bar")).toBe(true)
expect(Ignore.match("node_modules/bar/")).toBe(true)
})
test("parcel patterns ignore built-in folders at any depth", async () => {
let ignoreGlobs: string[] = []
const watcher = createWrapper({
+37 -52
View File
@@ -1,59 +1,44 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
describe("FileSystemSearch", () => {
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
observed = input
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
const it = testEffect(LayerNode.compile(Ripgrep.node))
await Effect.runPromise(
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
describe("Ripgrep", () => {
it.live("globs files as an array", () =>
withTmp((cwd) =>
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed?.limit).toBe(100_000)
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
),
)
it.live("greps files with include filtering", () =>
withTmp((cwd) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
),
)
})
-1
View File
@@ -30,7 +30,6 @@ const catalog = Layer.mock(Catalog.Service, {
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
-169
View File
@@ -1,169 +0,0 @@
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "fs/promises"
import nodePath from "path"
import { Effect, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
export const ROOT = "/workspace"
export const workspaceID = Workspace.ID.make("wrk_test")
/** A hosted Location at the workspace root, as hostedBoundNode would state it. */
export const hostedLocationLayer = (root: string = ROOT) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: AbsolutePath.make(root),
workspaceID,
project: Project.hostedGlobal(root),
}),
)
export interface MemoryEnvironment {
readonly environment: WorkspaceEnvironment.Interface
/** Live view of stored file contents by absolute path. */
readonly contents: (path: string) => string | undefined
}
/**
* Environment backed by a real host directory and spawner: the honest fake
* for end-to-end tests. Files go through fs/promises, commands actually run
* with the given spawner, so bash output is visible to reads.
*/
export const directoryEnvironment = (
root: string,
spawn: ChildProcessSpawner["Service"]["spawn"],
): WorkspaceEnvironment.Interface => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
Effect.tryPromise({
try: run,
catch: (cause) =>
(cause as NodeJS.ErrnoException).code === "ENOENT"
? new WorkspaceEnvironment.NotFoundError({ path })
: new WorkspaceEnvironment.Error({ operation, path, cause }),
})
return WorkspaceEnvironment.make({
directory: root,
files: {
stat: (path) =>
wrap("stat", path, async () => {
const info = await stat(path)
return {
type: info.isFile()
? ("File" as const)
: info.isDirectory()
? ("Directory" as const)
: ("Unknown" as const),
}
}),
realPath: (path) => wrap("realPath", path, () => realpath(path)),
read: (path) => wrap("read", path, async () => Uint8Array.from(await readFile(path))),
list: (path) =>
wrap("list", path, async () => {
const entries = await readdir(path, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
type: entry.isFile()
? ("file" as const)
: entry.isDirectory()
? ("directory" as const)
: entry.isSymbolicLink()
? ("symlink" as const)
: ("other" as const),
}))
}),
write: (path, content) =>
Effect.tryPromise({
try: async () => {
const existed = await stat(path).then(
(info) => info.isFile(),
() => false,
)
await mkdir(nodePath.dirname(path), { recursive: true })
await writeFile(path, content)
return { existed }
},
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => rm(path)),
},
process: make(spawn),
shell: {
executable: "/bin/bash",
args: (command) => ["-c", command],
// Real commands need PATH; nothing else from the host environment leaks.
environmentOverrides: { PATH: process.env.PATH ?? "" },
detached: false,
},
})
}
/**
* In-memory workspace environment rooted at /workspace: file paths to
* contents, directories implied by keys, no symlinks, no processes. Type
* mismatches (read a directory, list a file) fail with Error per the Files
* contract.
*/
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))]))
const isDirectory = (path: string) =>
path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/"))
const exists = (path: string) => store.has(path) || isDirectory(path)
const notFound = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
const wrongType = (operation: string, path: string) =>
Effect.fail(new WorkspaceEnvironment.Error({ operation, path }))
const environment = WorkspaceEnvironment.make({
directory: ROOT,
files: {
stat: (path) =>
store.has(path)
? Effect.succeed({ type: "File" as const })
: isDirectory(path)
? Effect.succeed({ type: "Directory" as const })
: notFound(path),
realPath: (path) => (exists(path) ? Effect.succeed(path) : notFound(path)),
read: (path) => {
const content = store.get(path)
if (content) return Effect.succeed(content)
return isDirectory(path) ? wrongType("read", path) : notFound(path)
},
list: (path) => {
if (store.has(path)) return wrongType("list", path)
if (!isDirectory(path)) return notFound(path)
const names = new Map<string, "file" | "directory">()
for (const key of store.keys()) {
if (!key.startsWith(path + "/")) continue
const rest = key.slice(path.length + 1)
const [head] = rest.split("/")
if (head) names.set(head, rest.includes("/") ? "directory" : "file")
}
return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type })))
},
write: (path, content) =>
Effect.sync(() => {
const existed = store.has(path)
store.set(path, Uint8Array.from(content))
return { existed }
}),
remove: (path) => {
if (store.delete(path)) return Effect.void
return isDirectory(path) ? wrongType("remove", path) : notFound(path)
},
},
process: make(() => Effect.die(new Error("no processes in the memory environment"))),
shell: WorkspaceEnvironment.linuxShell,
})
return {
environment,
contents: (path) => {
const stored = store.get(path)
return stored ? decoder.decode(stored) : undefined
},
}
}
+1
View File
@@ -21,6 +21,7 @@ const projectLayer = Layer.succeed(
canonical: AbsolutePath.make("/main/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
@@ -484,31 +484,4 @@ describe("OpencodePlugin", () => {
}),
),
)
it.effect("prefers gpt-5-nano as the opencode small model", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.opencode
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, Model.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(1, 1)]
model.time.released = Date.now()
})
catalog.model.update(providerID, Model.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(10, 10)]
model.time.released = Date.now()
})
})
const selected = yield* catalog.model.small(providerID)
expect(selected?.id).toBe(Model.ID.make("gpt-5-nano"))
}),
)
})
+11 -1
View File
@@ -83,10 +83,20 @@ describe("ProjectCopy", () => {
}),
)
it.effect("reports unavailable strategy ids", () =>
it.effect("rejects duplicate strategies and reports unavailable ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const strategy: ProjectCopy.Strategy = {
id: ProjectCopy.StrategyID.make("test/duplicate"),
create: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
list: () => Effect.succeed([]),
}
yield* copy.register(strategy)
expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
const error = yield* copy
.create({
+8 -4
View File
@@ -3,6 +3,7 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node))
@@ -45,14 +46,17 @@ describe("PTY websocket tickets", () => {
}),
)
it.live("rejects tickets scoped to a different pty", () =>
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID: PtyID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
})
+4 -1
View File
@@ -101,10 +101,13 @@ describe("RepositoryCache", () => {
),
)
it.live("returns typed branch validation and clone failures", () =>
it.live("returns typed validation and clone failures", () =>
withRemote((fixture) =>
Effect.gen(function* () {
const cache = yield* RepositoryCache.Service
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
-61
View File
@@ -11,44 +11,6 @@ import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(Ripgrep.node))
describe("Ripgrep", () => {
it.live("globs files as an array", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("greps files with include filtering", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
const result = yield* (yield* Ripgrep.Service).grep({
cwd: tmp.path,
pattern: "needle",
include: "*.ts",
limit: 10,
})
expect(result).toHaveLength(1)
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
expect(result[0]?.submatches[0]?.text).toBe("needle")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("keeps ignored files out of catch-all find results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -101,29 +63,6 @@ describe("Ripgrep", () => {
),
)
it.live("excludes protected directory trees from catch-all find results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
const files = yield* (yield* Ripgrep.Service).find({
cwd: tmp.path,
pattern: "*",
limit: 10,
exclude: ["Pictures/**"],
})
expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("returns a bounded preview for matches on oversized lines", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -36,6 +36,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
let requests: LLMRequest[] = []
+5 -2
View File
@@ -24,6 +24,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
@@ -33,6 +34,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
@@ -118,6 +120,7 @@ describe("Session.create", () => {
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -126,11 +129,11 @@ describe("Session.create", () => {
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory }),
location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: Agent.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory }, agent: "build", model })
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
}),
)
@@ -56,6 +56,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const permission = Layer.succeed(
+1
View File
@@ -23,6 +23,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
@@ -17,6 +17,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
@@ -125,6 +126,34 @@ describe("SessionProjector", () => {
}),
)
it.effect("folds live compaction deltas into running memory state", () =>
Effect.gen(function* () {
const state = {
messages: [
SessionMessage.CompactionRunning.make({
id: SessionMessage.ID.make("msg_compaction"),
type: "compaction",
status: "running",
reason: "manual",
summary: "partial ",
recent: "recent",
time: { created },
}),
],
}
yield* SessionMessageUpdater.update(
SessionMessageUpdater.memory(state),
SessionEvent.Compaction.Delta.make({
id: Event.ID.make("evt_delta"),
type: "session.compaction.delta",
created,
data: { sessionID, text: "summary" },
}),
)
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
}),
)
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -521,6 +550,31 @@ describe("SessionProjector", () => {
}),
)
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () {
const stale = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: build,
model,
content: [],
time: { created },
})
const completed = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: build,
model,
content: [],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
})
expect(
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
).toBeUndefined()
}),
)
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@@ -19,6 +19,7 @@ const projects = Layer.succeed(
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
@@ -102,7 +102,6 @@ const promptCatalog = Layer.mock(Catalog.Service, {
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
@@ -362,7 +362,6 @@ const promptCatalog = Layer.mock(Catalog.Service, {
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
+6
View File
@@ -34,6 +34,12 @@ describe("shell", () => {
expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false)
})
test("detects posix shells", () => {
expect(ShellSelect.posix("/bin/bash")).toBe(true)
expect(ShellSelect.posix("/bin/fish")).toBe(false)
expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = ShellSelect.preferred()
-135
View File
@@ -1,135 +0,0 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
// Replacement nodes may introduce tagged dependencies; declaring AppProcess
// lets the driver spawn through the real test spawner.
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-e2e-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-db-")), "e2e.db")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, AppProcess.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
],
),
)
describe("hosted workspace end to end", () => {
it.live("creates, works inside, evicts, and reconnects", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
expect(created.provider).toBe("fake")
// Metadata read round-trips through sqlite without touching the driver.
const fetched = yield* workspaces.get(created.id)
expect(fetched.root).toBe(created.root)
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
yield* Effect.gen(function* () {
const location = yield* Location.Service
expect(location.project.id).toBe(Project.ID.global)
expect(String(location.directory)).toBe(created.root)
const mutation = yield* LocationMutation.Service
const mutations = yield* FileMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
yield* mutations.write({ target, content: "hello from the workspace\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "printf 'from-bash' > bash.txt && cat hello.txt",
timeout: 30_000,
})
const finished = yield* shell.wait(command.id)
expect(finished.status).toBe("exited")
const output = yield* shell.output(command.id)
expect(output.output).toContain("hello from the workspace")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(1)
// Evict the cached graph, then reconnect through the driver: the
// workspace contents survive because the binding names durable state.
yield* locations.invalidate(ref)
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
expect(new TextDecoder().decode(hello.content)).toBe("hello from the workspace\n")
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(2)
}),
)
})
@@ -1,57 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
"/workspace/src/util/deep.ts": "export const deep = 1\n",
})
const it = testEffect(
AppNodeBuilder.build(FileSystem.hostedNode, [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
)
describe("hosted FileSystem", () => {
it.effect("reads a file through the environment", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("README.md") })
expect(new TextDecoder().decode(result.content)).toBe("# hello\n")
expect(result.mime).toBe("text/markdown")
}),
)
it.effect("lists directories before files with posix separators", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("src") })
expect(entries.map((entry) => String(entry.path))).toEqual(["src/util/", "src/index.ts"])
}),
)
it.effect("lists the root when no path is given", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
expect(entries.map((entry) => String(entry.path))).toEqual(["src/", "README.md"])
}),
)
it.effect("refuses paths that escape the workspace", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const exit = yield* filesystem.read({ path: RelativePath.make("../etc/passwd") }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})
@@ -1,67 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode]), [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
)
describe("hosted mutation", () => {
it.effect("resolves an existing file to its canonical path and relative resource", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "README.md" })
expect(target.canonical).toBe("/workspace/README.md")
expect(target.resource).toBe("README.md")
expect(target.externalDirectory).toBeUndefined()
}),
)
it.effect("resolves a missing path below an existing directory", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "src/created/new.ts" })
expect(target.canonical).toBe("/workspace/src/created/new.ts")
expect(target.resource).toBe("src/created/new.ts")
}),
)
it.effect("rejects paths outside the workspace instead of granting external access", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const error = yield* mutation.resolve({ path: "/etc/passwd" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}),
)
it.effect("writes new and existing files through the environment", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const created = yield* mutation.resolve({ path: "notes/todo.md" })
const first = yield* files.write({ target: created, content: "- ship it\n" })
expect(first.existed).toBe(false)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- ship it\n")
const second = yield* files.writeTextPreservingBom({ target: created, content: "- shipped\n" })
expect(second.existed).toBe(true)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
})
-161
View File
@@ -1,161 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin } from "./lib/tool"
import { hostedLocationLayer, memoryEnvironment, ROOT } from "./lib/workspace"
import type { MemoryEnvironment } from "./lib/workspace"
const sessionID = Session.ID.make("ses_workspace_patch_test")
const assertions: Permission.AssertInput[] = []
const permission = Layer.succeed(
Permission.Service,
Permission.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
// Hosted patch must never format nor touch the host filesystem: both die.
const formatter = Layer.mock(Formatter.Service, {
file: () => Effect.die("hosted patch must not run host formatters"),
})
const poisoned = () => Effect.die("hosted patch must not touch the host filesystem")
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
stat: poisoned,
readFile: poisoned,
writeWithDirs: poisoned,
remove: poisoned,
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(memory: MemoryEnvironment, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const environment = Layer.succeed(WorkspaceEnvironment.Service, memory.environment)
const patchToolNode = makeLocationNode({
name: "test/workspace-patch-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node, WorkspaceEnvironment.node],
})
return Effect.gen(function* () {
assertions.length = 0
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, hostedLocationLayer()],
[Formatter.node, formatter],
[Permission.node, permission],
[WorkspaceEnvironment.node, environment],
]),
),
)
}
const call = (patchText: string, id = "call-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
const it = testEffect(Layer.empty)
describe("PatchTool on a hosted location", () => {
it.effect("applies add, update, and delete through the workspace environment", () => {
const memory = memoryEnvironment({
[`${ROOT}/update.txt`]: "before\n",
[`${ROOT}/remove.txt`]: "remove\n",
})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt", target: `${ROOT}/nested/new.txt` },
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
})
expect(memory.contents(`${ROOT}/nested/new.txt`)).toBe("created\n")
expect(memory.contents(`${ROOT}/update.txt`)).toBe("after\n")
expect(memory.contents(`${ROOT}/remove.txt`)).toBeUndefined()
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["nested/new.txt", "update.txt", "remove.txt"])
}),
)
})
it.effect("moves a file through the workspace environment", () => {
const memory = memoryEnvironment({ [`${ROOT}/old.txt`]: "before\n" })
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("completed")
expect(memory.contents(`${ROOT}/old.txt`)).toBeUndefined()
expect(memory.contents(`${ROOT}/moved.txt`)).toBe("after\n")
}),
)
})
it.effect("reports a missing update target as a verification failure", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("file does not exist")
}),
)
})
it.effect("resolves external targets with posix containment", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
yield* executeTool(registry, call("*** Begin Patch\n*** Add File: /outside/new.txt\n+created\n*** End Patch"))
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.metadata).toMatchObject({ filepath: "/outside/new.txt", parentDir: "/outside" })
}),
)
})
})
@@ -1,174 +0,0 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp, readFile } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-session-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
// the only editor, matching production GPT-5 sessions.
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
})
// Real permission flow would ask; the runner path under test is admission
// through drain, so approve everything.
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-session-db-")), "e2e.db")
// The real SessionExecution + Session nodes: drains route through
// LocationServiceMap.get(session.location), which is the seam under test.
// Replacements flow into every hoisted per-location graph via AppNodeBuilder.
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
AppProcess.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
[SessionRunnerModel.node, models],
[Permission.node, permission],
],
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] }))),
)
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
describe("hosted workspace session", () => {
it.live("runs a scripted model through the real runner on a hosted Location", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "hosted session",
location: Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
}),
model: sessionModel,
})
yield* TestLLM.push(
TestLLM.tool("call-patch", "patch", {
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
}),
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
yield* sessions.resume(session.id)
// Both tools executed inside the workspace, not on a host path.
const written = yield* Effect.promise(() => readFile(path.join(created.root, "from-model.txt"), "utf8"))
expect(written).toBe("from-model")
const patched = yield* Effect.promise(() => readFile(path.join(created.root, "from-patch.txt"), "utf8"))
expect(patched).toBe("patched\n")
// The hosted catalog was advertised to the model: shell and patch (the
// gpt-style editor), without host-bound search tools or edit/write.
const requests = (yield* TestLLM.Service).requests
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
expect(advertised).toContain("shell")
expect(advertised).toContain("patch")
expect(advertised).not.toContain("glob")
expect(advertised).not.toContain("grep")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
// Projected history shows the completed tool call and the final text.
// Instruction updates (e.g. the Code Mode catalog settling after boot)
// may interleave, so assert on the user and assistant messages directly.
const context = yield* sessions.context(session.id)
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
const assistants = context.filter((message) => message.type === "assistant")
expect(assistants.at(0)).toMatchObject({
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
})
expect(assistants.at(1)).toMatchObject({
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
}),
)
})
+12
View File
@@ -5,3 +5,15 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "virtual:opencode-server" {
export namespace Server {
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
}
export namespace Config {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}
+181
View File
@@ -1,8 +1,32 @@
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
export type SidecarListener = { stop: () => Promise<void> }
const SIDECAR_SERVICE_NAME = "opencode server"
const SIDECAR_START_STALL_TIMEOUT = 60_000
const SIDECAR_STOP_TIMEOUT = 6_000
type SpawnLocalServerOptions = {
userDataPath: string
onStdout?: (message: string) => void
onStderr?: (message: string) => void
onExit?: (code: number) => void
}
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
@@ -30,6 +54,135 @@ export function preferAppEnv(userDataPath: string) {
return shellEnv
}
export async function spawnLocalServer(
hostname: string,
port: number,
password: string,
options: SpawnLocalServerOptions,
) {
const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
const child = utilityProcess.fork(sidecar, [], {
cwd: process.cwd(),
env: createSidecarEnv(),
serviceName: SIDECAR_SERVICE_NAME,
stdio: "pipe",
})
let exited = false
const exit = defer<number>()
const onProcessGone = (_event: unknown, details: Details) => {
if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
}
app.on("child-process-gone", onProcessGone)
child.once("exit", (code) => {
exited = true
app.off("child-process-gone", onProcessGone)
options.onExit?.(code)
exit.resolve(code)
})
child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
await new Promise<void>((resolve, reject) => {
let done = false
let timeout: NodeJS.Timeout
const fail = (error: Error) => {
if (done) return
done = true
cleanup()
reject(error)
}
const refreshTimeout = () => {
clearTimeout(timeout)
timeout = setTimeout(() => {
fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
}, SIDECAR_START_STALL_TIMEOUT)
}
const onMessage = (message: SidecarMessage) => {
if (message.type === "ready") {
if (done) return
done = true
cleanup()
resolve()
return
}
if (message.type === "error") {
fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
}
}
const onExit = (code: number) => {
fail(new Error(`Sidecar exited before ready with code ${code}`))
}
const cleanup = () => {
clearTimeout(timeout)
child.off("message", onMessage)
child.off("exit", onExit)
}
child.on("message", onMessage)
child.on("exit", onExit)
refreshTimeout()
child.postMessage({
type: "start",
hostname,
port,
password,
userDataPath: options.userDataPath,
})
}).catch((error) => {
if (!exited) child.kill()
throw error
})
const wait = (async () => {
const url = `http://${hostname}:${port}`
let healthy = false
const gone = exit.promise.then((code) => {
if (healthy) return
throw new Error(`Sidecar exited before health check passed with code ${code}`)
})
const ready = async () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) {
healthy = true
return
}
}
}
await Promise.race([ready(), gone])
})()
let stopping: Promise<void> | undefined
return {
listener: {
stop: () => {
if (stopping) return stopping
if (exited) return Promise.resolve()
child.postMessage({ type: "stop" })
stopping = Promise.race([
exit.promise.then(() => undefined),
delay(SIDECAR_STOP_TIMEOUT).then(() => {
if (!exited) child.kill()
}),
])
return stopping
},
},
health: { wait },
}
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrls: URL[]
try {
@@ -56,3 +209,31 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
return false
}
function createSidecarEnv(): Record<string, string> {
const env = Object.fromEntries(
Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
)
delete env.DEBUG
if (process.platform === "linux") delete env.LD_PRELOAD
return env
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+157
View File
@@ -0,0 +1,157 @@
import * as http from "node:http"
import * as tls from "node:tls"
type NodeHttpWithEnvProxy = typeof http & {
setGlobalProxyFromEnv: () => void
}
type NodeTlsWithSystemCertificates = typeof tls & {
getCACertificates: (type: "default" | "system") => string[]
setDefaultCACertificates: (certificates: string[]) => void
}
type StartCommand = {
type: "start"
hostname: string
port: number
password: string
userDataPath: string
}
type StopCommand = { type: "stop" }
type SidecarCommand = StartCommand | StopCommand
type SidecarMessage =
| { type: "ready" }
| { type: "stopped" }
| { type: "error"; error: { message: string; stack?: string } }
type ParentPort = {
postMessage(message: SidecarMessage): void
on(event: "message", listener: (event: { data: unknown }) => void): void
}
type Listener = {
stop(close?: boolean): void | Promise<void>
}
const parentPort = getParentPort()
let listener: Listener | undefined
parentPort.on("message", (event) => {
const command = parseCommand(event.data)
if (!command) return
if (command.type === "stop") {
void stop()
return
}
void start(command)
})
async function start(command: StartCommand) {
try {
prepareSidecarEnv(command.password, command.userDataPath)
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Server } = await import("virtual:opencode-server")
listener = await Server.listen({
port: command.port,
hostname: command.hostname,
username: "opencode",
password: command.password,
cors: ["oc://renderer"],
})
parentPort.postMessage({ type: "ready" })
} catch (error) {
parentPort.postMessage({ type: "error", error: serializeError(error) })
setImmediate(() => process.exit(1))
}
}
async function stop() {
try {
await listener?.stop()
} finally {
listener = undefined
parentPort.postMessage({ type: "stopped" })
setImmediate(() => process.exit(0))
}
}
function prepareSidecarEnv(password: string, userDataPath: string) {
Object.assign(process.env, {
OPENCODE_SERVER_USERNAME: "opencode",
OPENCODE_SERVER_PASSWORD: password,
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
function useSystemCertificates() {
try {
const nodeTls = tls as NodeTlsWithSystemCertificates
nodeTls.setDefaultCACertificates([
...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
])
} catch (error) {
console.warn("failed to load system certificates", error)
}
}
function useEnvProxy() {
try {
;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
} catch (error) {
console.warn("failed to load proxy environment", error)
}
}
function parseCommand(value: unknown): SidecarCommand | undefined {
if (!value || typeof value !== "object") return
const command = value as Partial<StartCommand | StopCommand>
if (command.type === "stop") return { type: "stop" }
if (command.type !== "start") return
if (typeof command.hostname !== "string") return
if (typeof command.port !== "number") return
if (typeof command.password !== "string") return
if (typeof command.userDataPath !== "string") return
return {
type: "start",
hostname: command.hostname,
port: command.port,
password: command.password,
userDataPath: command.userDataPath,
}
}
function serializeError(error: unknown) {
if (error instanceof Error) return { message: error.message, stack: error.stack }
return { message: String(error) }
}
function getParentPort() {
const port = process.parentPort as ParentPort | undefined
if (!port) throw new Error("Sidecar parent port unavailable")
return port
}
+33
View File
@@ -23,11 +23,44 @@ export type ProviderContext = {
options: Record<string, any>
}
export type WorkspaceInfo = {
id: string
type: string
name: string
branch: string | null
directory: string | null
extra: unknown | null
projectID: string
}
export type WorkspaceTarget =
| {
type: "local"
directory: string
}
| {
type: "remote"
url: string | URL
headers?: HeadersInit
}
export type WorkspaceAdapter = {
name: string
description: string
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
}
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void
}
serverUrl: URL
$: BunShell
}
+1
View File
@@ -6,6 +6,7 @@ export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
+1 -1
View File
@@ -130,7 +130,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
"x-websocket": true,
parameters: [
...(operation.parameters ?? []),
...["location[directory]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
+2
View File
@@ -6,6 +6,7 @@ import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
@@ -41,6 +42,7 @@ const ParentIDFilter = Schema.Union([
})
const SessionsQueryFields = {
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
@@ -6,6 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
+2
View File
@@ -32,6 +32,7 @@ import { SessionStatusEvent } from "./session-status-event.js"
import { SessionV1 } from "./session-v1.js"
import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js"
@@ -99,6 +100,7 @@ export const Definitions = Event.inventory(
...SessionStatusEvent.Definitions,
...SessionCompactionEvent.Definitions,
...VcsEvent.Definitions,
...WorkspaceEvent.Definitions,
...WorktreeEvent.Definitions,
...ServerEvent.Definitions,
)
+32
View File
@@ -0,0 +1,32 @@
export * as WorkspaceEvent from "./workspace-event.js"
import { Schema } from "effect"
import { Event } from "./event.js"
import { WorkspaceID } from "./workspace-id.js"
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" })
export interface ConnectionStatus extends Schema.Schema.Type<typeof ConnectionStatus> {}
export const Ready = Event.ephemeral({
type: "workspace.ready",
schema: {
name: Schema.String,
},
})
export const Failed = Event.ephemeral({
type: "workspace.failed",
schema: {
message: Schema.String,
},
})
export const Status = Event.ephemeral({
type: "workspace.status",
schema: ConnectionStatus.fields,
})
export const Definitions = Event.inventory(Ready, Failed, Status)
+3
View File
@@ -1,6 +1,9 @@
export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js"
export const ID = WorkspaceID
export type ID = WorkspaceID
export const Event = WorkspaceEvent

Some files were not shown because too many files have changed in this diff Show More