Compare commits

..

3 Commits

Author SHA1 Message Date
Dax Raad 0a8da2c985 fix(api): require session selection 2026-08-07 01:32:23 +00:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
112 changed files with 2334 additions and 5400 deletions
-40
View File
@@ -678,10 +678,8 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"modal": "0.9.0",
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@@ -1513,18 +1511,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=="], "@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=="], "@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=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1733,10 +1719,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=="], "@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=="], "@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=="], "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1831,8 +1813,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=="], "@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=="], "@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=="], "@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=="],
@@ -3317,8 +3297,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "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=="], "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=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3559,10 +3537,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "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=="], "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=="], "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=="],
@@ -4595,8 +4569,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "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.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4823,8 +4795,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "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=="], "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=="], "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=="],
@@ -4863,10 +4833,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], "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=="], "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=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5665,8 +5631,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "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=="], "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=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6625,8 +6589,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=="], "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/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=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6771,8 +6733,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "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=="], "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=="], "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=="],
@@ -26,6 +26,7 @@ export default Runtime.handler(
const location = await client.location.get({ location: { directory: process.cwd() } }) const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({ const page = await client.session.list({
directory: location.directory, directory: location.directory,
workspace: location.workspaceID,
parentID: null, parentID: null,
order: "desc", order: "desc",
limit: 50, limit: 50,
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
) => ) =>
resolveSessionTarget({ resolveSessionTarget({
client, client,
location: { directory: next.location.directory }, location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent, agent: next.agent,
model: next.model model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } ? { 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) ? Promise.resolve(undefined)
: input.client.form.request : input.client.form.request
.list({ .list({
location: { directory: input.location.directory }, location: { directory: input.location.directory, workspace: input.location.workspaceID },
}) })
.catch(() => undefined), .catch(() => undefined),
]) ])
@@ -757,6 +757,7 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
{ {
headers: { headers: {
"x-opencode-directory": encodeURIComponent(location.directory), "x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
}, },
}, },
] ]
+9 -6
View File
@@ -94,11 +94,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
prepare: async (next) => { prepare: async (next) => {
const selected = const selected =
next.model ?? next.model ??
(options.variant (await client.model
? await client.model .default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.default({ location: { directory: next.location.directory } }) .then((result) => result.data))
.then((result) => result.data)
: undefined)
const model = selected const model = selected
? { ? {
providerID: selected.providerID, providerID: selected.providerID,
@@ -108,7 +106,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
: undefined : undefined
if ((options.variant ?? explicit?.variant) && !model) if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id) throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
return { model, agent: next.agent } const agent =
next.agent ??
(await client.agent
.list({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data.find((item) => item.mode !== "subagent" && !item.hidden)?.id))
return { model, agent }
}, },
}).catch((error) => { }).catch((error) => {
if (!(error instanceof RunTargetError)) throw error if (!(error instanceof RunTargetError)) throw error
+6 -2
View File
@@ -56,13 +56,16 @@ export async function resolveSessionTarget(input: {
agent: input.agent ?? selected?.agent, agent: input.agent ?? selected?.agent,
signal: input.signal, signal: input.signal,
}) })
if (!selected && (!prepared.agent || !prepared.model)) {
throw new SessionTargetMutationError(new Error("Creating a session requires an agent and model"))
}
const session = const session =
selected ?? selected ??
(await input.client.session (await input.client.session
.create( .create(
{ {
agent: prepared.agent, agent: prepared.agent!,
model: prepared.model, model: prepared.model!,
location: { directory: location.directory, workspaceID: location.workspaceID }, location: { directory: location.directory, workspaceID: location.workspaceID },
}, },
...requestOptions(input.signal), ...requestOptions(input.signal),
@@ -136,6 +139,7 @@ async function latestSession(
const page = await client.session.list( const page = await client.session.list(
{ {
directory: location.directory, directory: location.directory,
workspace: location.workspaceID,
parentID: null, parentID: null,
limit: SESSION_PAGE_LIMIT, limit: SESSION_PAGE_LIMIT,
order: "desc", order: "desc",
+2 -1
View File
@@ -426,13 +426,14 @@ describe("runNonInteractivePrompt", () => {
const globalOptions = { const globalOptions = {
headers: { headers: {
"x-opencode-directory": "%2Fwork%20tree", "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: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" }) 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.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({ 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.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled() expect(sdk.question.reject).not.toHaveBeenCalled()
+12 -6
View File
@@ -61,7 +61,11 @@ describe("session target resolver", () => {
spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1")) spyOn(client.location, "get").mockResolvedValue(location("/server", "work_1"))
const create = spyOn(client.session, "create").mockImplementation(async (input) => { const create = spyOn(client.session, "create").mockImplementation(async (input) => {
order.push("create") order.push("create")
expect(input).toMatchObject({ agent: "prepared", location: { directory: "/server", workspaceID: "work_1" } }) expect(input).toMatchObject({
agent: "prepared",
model: { providerID: "openai", id: "gpt-5" },
location: { directory: "/server", workspaceID: "work_1" },
})
return session("ses_fresh", "/server", "work_1") return session("ses_fresh", "/server", "work_1")
}) })
@@ -71,20 +75,22 @@ describe("session target resolver", () => {
prepare: async (input) => { prepare: async (input) => {
order.push("prepare") order.push("prepare")
expect(input.location.workspaceID).toBe("work_1") expect(input.location.workspaceID).toBe("work_1")
return { model: input.model, agent: "prepared" } return { model: { providerID: "openai", id: "gpt-5" }, agent: "prepared" }
}, },
}) })
expect(create).toHaveBeenCalledTimes(1) expect(create).toHaveBeenCalledTimes(1)
expect(order).toEqual(["prepare", "create"]) expect(order).toEqual(["prepare", "create"])
}) })
test("uses the agent resolved by the server for a fresh Session", async () => { test("requires an explicit agent and model for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" }) const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project")) spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" }) const create = spyOn(client.session, "create")
const target = await resolveSessionTarget({ client, prepare }) await expect(resolveSessionTarget({ client, prepare })).rejects.toThrow(
expect(target.agent).toBe("review") "Creating a session requires an agent and model",
)
expect(create).not.toHaveBeenCalled()
}) })
test("does not retry an ambiguous Session creation", async () => { test("does not retry an ambiguous Session creation", async () => {
+110 -63
View File
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
import type { Location } from "@opencode-ai/schema/location" import type { Location } from "@opencode-ai/schema/location"
import type { Agent } from "@opencode-ai/schema/agent" import type { Agent } from "@opencode-ai/schema/agent"
import type { Plugin } from "@opencode-ai/schema/plugin" 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 { Session } from "@opencode-ai/schema/session"
import type { AbsolutePath } from "@opencode-ai/schema/schema" import type { AbsolutePath } from "@opencode-ai/schema/schema"
import type { Project } from "@opencode-ai/schema/project" import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema" import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect" import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model" 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 { SessionMessage } from "@opencode-ai/schema/session-message"
import type { PromptInput } from "@opencode-ai/schema/prompt-input" import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt" import type { AgentAttachment } from "@opencode-ai/schema/prompt"
@@ -59,7 +59,9 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E> 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 Endpoint2_0Output = Location.Info
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E> export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
@@ -67,13 +69,15 @@ export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E> 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 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 AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
export type Endpoint3_1Input = { export type Endpoint3_1Input = {
readonly agentID: Agent.ID 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 Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E> export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
@@ -83,7 +87,9 @@ export interface AgentApi<E = never> {
readonly get: AgentGetOperation<E> 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 Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E> export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
@@ -92,6 +98,7 @@ export interface PluginApi<E = never> {
} }
export type Endpoint5_0Input = { export type Endpoint5_0Input = {
readonly workspace?: Workspace.ID | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -113,12 +120,12 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = { export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined readonly id?: Session.ID | undefined
readonly title?: string | undefined readonly title?: string | undefined
readonly agent?: Agent.ID | undefined readonly agent: Agent.ID
readonly model?: Model.Ref | undefined readonly model: Model.Ref
readonly location?: Location.Ref | undefined readonly location?: Location.Ref | undefined
} }
export type Endpoint5_1Output = Session.Info export type Endpoint5_1Output = Session.Info
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E> export type SessionCreateOperation<E = never> = (input: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
export type Endpoint5_2Input = { export type Endpoint5_2Input = {
readonly info: Session.Info readonly info: Session.Info
@@ -926,11 +933,15 @@ export interface MessageApi<E = never> {
readonly list: MessageListOperation<E> 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 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 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 Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E> export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
@@ -940,7 +951,7 @@ export interface ModelApi<E = never> {
} }
export type Endpoint8_0Input = { 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 prompt: string
readonly model?: Model.Ref | undefined readonly model?: Model.Ref | undefined
} }
@@ -951,13 +962,15 @@ export interface GenerateApi<E = never> {
readonly text: GenerateTextOperation<E> 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 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 ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
export type Endpoint9_1Input = { export type Endpoint9_1Input = {
readonly providerID: Provider.ID 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 Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E> export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
@@ -967,19 +980,21 @@ export interface ProviderApi<E = never> {
readonly get: ProviderGetOperation<E> 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 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 IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
export type Endpoint10_1Input = { export type Endpoint10_1Input = {
readonly integrationID: Integration.ID 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 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 IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export type Endpoint10_2Input = { 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 readonly url: string
} }
export type Endpoint10_2Output = void export type Endpoint10_2Output = void
@@ -989,7 +1004,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
export type Endpoint10_3Input = { export type Endpoint10_3Input = {
readonly integrationID: Integration.ID 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 key: string
readonly label?: string | undefined readonly label?: string | undefined
} }
@@ -1000,7 +1015,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
export type Endpoint10_4Input = { export type Endpoint10_4Input = {
readonly integrationID: Integration.ID 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 methodID: Integration.MethodID
readonly inputs: { readonly [x: string]: string } readonly inputs: { readonly [x: string]: string }
readonly label?: string | undefined readonly label?: string | undefined
@@ -1013,7 +1028,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
export type Endpoint10_5Input = { export type Endpoint10_5Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID 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 Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
export type IntegrationOauthStatusOperation<E = never> = ( export type IntegrationOauthStatusOperation<E = never> = (
@@ -1023,7 +1038,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
export type Endpoint10_6Input = { export type Endpoint10_6Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID 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 readonly code?: string | undefined
} }
export type Endpoint10_6Output = void export type Endpoint10_6Output = void
@@ -1034,7 +1049,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
export type Endpoint10_7Input = { export type Endpoint10_7Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID 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 Endpoint10_7Output = void
export type IntegrationOauthCancelOperation<E = never> = ( export type IntegrationOauthCancelOperation<E = never> = (
@@ -1043,7 +1058,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
export type Endpoint10_8Input = { export type Endpoint10_8Input = {
readonly integrationID: Integration.ID 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 methodID: Integration.MethodID
readonly label?: string | undefined readonly label?: string | undefined
} }
@@ -1055,7 +1070,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
export type Endpoint10_9Input = { export type Endpoint10_9Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID 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 Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
export type IntegrationCommandStatusOperation<E = never> = ( export type IntegrationCommandStatusOperation<E = never> = (
@@ -1065,7 +1080,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
export type Endpoint10_10Input = { export type Endpoint10_10Input = {
readonly integrationID: Integration.ID readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID 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 Endpoint10_10Output = void
export type IntegrationCommandCancelOperation<E = never> = ( export type IntegrationCommandCancelOperation<E = never> = (
@@ -1090,13 +1105,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 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 McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
export type Endpoint11_1Input = { export type Endpoint11_1Input = {
readonly server: string 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 readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
} }
export type Endpoint11_1Output = void export type Endpoint11_1Output = void
@@ -1104,26 +1121,28 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
export type Endpoint11_2Input = { export type Endpoint11_2Input = {
readonly server: string 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 Endpoint11_2Output = void
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E> export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
export type Endpoint11_3Input = { export type Endpoint11_3Input = {
readonly server: string 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 Endpoint11_3Output = void
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E> export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export type Endpoint11_4Input = { export type Endpoint11_4Input = {
readonly server: string 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 Endpoint11_4Output = void
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E> 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 Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E> export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
@@ -1138,7 +1157,7 @@ export interface McpApi<E = never> {
export type Endpoint12_0Input = { export type Endpoint12_0Input = {
readonly credentialID: Credential.ID 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 readonly label: string
} }
export type Endpoint12_0Output = void export type Endpoint12_0Output = void
@@ -1146,7 +1165,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
export type Endpoint12_1Input = { export type Endpoint12_1Input = {
readonly credentialID: Credential.ID 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 Endpoint12_1Output = void
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E> export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
@@ -1159,13 +1178,15 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = ReadonlyArray<Project.Info> export type Endpoint13_0Output = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E> 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 Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E> export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = { export type Endpoint13_2Input = {
readonly projectID: Project.ID 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 Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E> export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
@@ -1176,7 +1197,9 @@ export interface ProjectApi<E = never> {
readonly directories: ProjectDirectoriesOperation<E> 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 Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E> export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
@@ -1220,7 +1243,9 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E> 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 Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
export type PermissionRequestListOperation<E = never> = ( export type PermissionRequestListOperation<E = never> = (
input?: Endpoint15_0Input, input?: Endpoint15_0Input,
@@ -1278,14 +1303,14 @@ export interface PermissionApi<E = never> {
} }
export type Endpoint16_0Input = { 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 readonly path?: RelativePath | undefined
} }
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> } 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 FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
export type Endpoint16_1Input = { 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 query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
@@ -1298,7 +1323,9 @@ export interface FileApi<E = never> {
readonly find: FileFindOperation<E> 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 Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E> export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
@@ -1306,7 +1333,9 @@ export interface CommandApi<E = never> {
readonly list: CommandListOperation<E> 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 Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E> export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
@@ -1321,12 +1350,14 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E> 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 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 PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
export type Endpoint20_1Input = { 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 command?: string | undefined
readonly args?: ReadonlyArray<string> | undefined readonly args?: ReadonlyArray<string> | undefined
readonly cwd?: string | undefined readonly cwd?: string | undefined
@@ -1338,14 +1369,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
export type Endpoint20_2Input = { export type Endpoint20_2Input = {
readonly ptyID: Pty.ID 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 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 PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_3Input = { export type Endpoint20_3Input = {
readonly ptyID: Pty.ID 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 title?: string | undefined
readonly size?: { readonly rows: number; readonly cols: number } | undefined readonly size?: { readonly rows: number; readonly cols: number } | undefined
} }
@@ -1354,7 +1385,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
export type Endpoint20_4Input = { export type Endpoint20_4Input = {
readonly ptyID: Pty.ID 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 Endpoint20_4Output = void
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E> export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
@@ -1367,12 +1398,14 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E> 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 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 ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = { 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 command: string
readonly cwd?: string | undefined readonly cwd?: string | undefined
readonly timeout: number readonly timeout: number
@@ -1383,14 +1416,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
export type Endpoint21_2Input = { export type Endpoint21_2Input = {
readonly id: Shell.ID 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 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 ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = { export type Endpoint21_3Input = {
readonly id: Shell.ID 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 readonly timeout: number
} }
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info } export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
@@ -1398,7 +1431,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
export type Endpoint21_4Input = { export type Endpoint21_4Input = {
readonly id: Shell.ID 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 cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
} }
@@ -1415,7 +1448,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
export type Endpoint21_5Input = { export type Endpoint21_5Input = {
readonly id: Shell.ID 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 Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E> export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
@@ -1429,7 +1462,9 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E> 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 Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = ( export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input, input?: Endpoint22_0Input,
@@ -1458,7 +1493,9 @@ export interface QuestionApi<E = never> {
readonly reject: QuestionRejectOperation<E> 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 Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E> export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
@@ -1468,7 +1505,7 @@ export interface ReferenceApi<E = never> {
export type Endpoint24_0Input = { export type Endpoint24_0Input = {
readonly projectID: Project.ID 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 strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath readonly directory: AbsolutePath
readonly name?: string | undefined readonly name?: string | undefined
@@ -1478,7 +1515,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
export type Endpoint24_1Input = { export type Endpoint24_1Input = {
readonly projectID: Project.ID 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 directory: AbsolutePath
readonly force: boolean readonly force: boolean
} }
@@ -1487,7 +1524,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
export type Endpoint24_2Input = { export type Endpoint24_2Input = {
readonly projectID: Project.ID 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 Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E> export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
@@ -1498,16 +1535,20 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E> 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 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 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 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 VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = { 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 mode: Vcs.Mode
readonly context?: number | undefined readonly context?: number | undefined
} }
@@ -1523,7 +1564,9 @@ export interface VcsApi<E = never> {
export type Endpoint26_0Output = ReadonlyArray<Location.Ref> export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E> 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 Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E> export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
@@ -1548,12 +1591,14 @@ export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> } readonly v1: { readonly status: MigrationV1StatusOperation<E> }
} }
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> } export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E> export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint28_1Input = { export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly providerID?: WebSearch.ID | undefined readonly providerID?: WebSearch.ID | undefined
} }
@@ -1565,7 +1610,9 @@ export interface WebsearchApi<E = never> {
readonly query: WebsearchQueryOperation<E> readonly query: WebsearchQueryOperation<E>
} }
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined } export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry> export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E> export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
@@ -292,6 +292,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
preserveEffect<Endpoint5_0Output>()( preserveEffect<Endpoint5_0Output>()(
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.["workspace"],
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
@@ -304,15 +305,15 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) => const Endpoint5_1 = (raw: RawClient["server.session"]) => (input: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()( preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({ raw["session.create"]({
payload: { payload: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
}).pipe( }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -448,6 +448,7 @@ export function make(options: ClientOptions) {
method: "GET", method: "GET",
path: `/api/session`, path: `/api/session`,
query: { query: {
workspace: input?.["workspace"],
limit: input?.["limit"], limit: input?.["limit"],
order: input?.["order"], order: input?.["order"],
search: input?.["search"], search: input?.["search"],
@@ -463,20 +464,20 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) => create: (input: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>( request<{ readonly data: SessionCreateOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session`, path: `/api/session`,
body: { body: {
id: input?.["id"], id: input["id"],
title: input?.["title"], title: input["title"],
agent: input?.["agent"], agent: input["agent"],
model: input?.["model"], model: input["model"],
location: input?.["location"], location: input["location"],
}, },
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [401, 400],
empty: false, empty: false,
}, },
requestOptions, requestOptions,
+211 -80
View File
@@ -2283,7 +2283,9 @@ export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> } export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = { 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 = { export type LocationGetOutput = {
@@ -2293,7 +2295,9 @@ export type LocationGetOutput = {
} }
export type AgentListInput = { 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 = { export type AgentListOutput = {
@@ -2303,7 +2307,9 @@ export type AgentListOutput = {
export type AgentGetInput = { export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"] 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 = { export type AgentGetOutput = {
@@ -2312,7 +2318,9 @@ export type AgentGetOutput = {
} }
export type PluginListInput = { 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 = { export type PluginListOutput = {
@@ -2321,7 +2329,19 @@ export type PluginListOutput = {
} }
export type SessionListInput = { 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 limit?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2332,6 +2352,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["limit"] }["limit"]
readonly order?: { readonly order?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2342,6 +2363,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["order"] }["order"]
readonly search?: { readonly search?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2352,6 +2374,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["search"] }["search"]
readonly parentID?: { readonly parentID?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2362,6 +2385,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["parentID"] }["parentID"]
readonly directory?: { readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2372,6 +2396,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["directory"] }["directory"]
readonly project?: { readonly project?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2382,6 +2407,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["project"] }["project"]
readonly subpath?: { readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2392,6 +2418,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined readonly cursor?: string | undefined
}["subpath"] }["subpath"]
readonly cursor?: { readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined readonly search?: string | undefined
@@ -2409,36 +2436,36 @@ export type SessionCreateInput = {
readonly id?: { readonly id?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"] }["id"]
readonly title?: { readonly title?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"] }["title"]
readonly agent?: { readonly agent: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"] }["agent"]
readonly model?: { readonly model: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"] }["model"]
readonly location?: { readonly location?: {
readonly id?: string | null readonly id?: string | null
readonly title?: string | null readonly title?: string | null
readonly agent?: string | null readonly agent: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"] }["location"]
} }
@@ -3733,7 +3760,9 @@ export type MessageListInput = {
export type MessageListOutput = SessionMessagesResponse export type MessageListOutput = SessionMessagesResponse
export type ModelListInput = { 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 = { export type ModelListOutput = {
@@ -3742,7 +3771,9 @@ export type ModelListOutput = {
} }
export type ModelDefaultInput = { 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 = { export type ModelDefaultOutput = {
@@ -3751,7 +3782,9 @@ export type ModelDefaultOutput = {
} }
export type GenerateTextInput = { 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: {
readonly prompt: string readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -3765,7 +3798,9 @@ export type GenerateTextInput = {
export type GenerateTextOutput = GenerateTextResponse["data"] export type GenerateTextOutput = GenerateTextResponse["data"]
export type ProviderListInput = { 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 = { export type ProviderListOutput = {
@@ -3775,7 +3810,9 @@ export type ProviderListOutput = {
export type ProviderGetInput = { export type ProviderGetInput = {
readonly providerID: { readonly providerID: string }["providerID"] 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 = { export type ProviderGetOutput = {
@@ -3784,7 +3821,9 @@ export type ProviderGetOutput = {
} }
export type IntegrationListInput = { 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 = { export type IntegrationListOutput = {
@@ -3794,7 +3833,9 @@ export type IntegrationListOutput = {
export type IntegrationGetInput = { export type IntegrationGetInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] 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 = { export type IntegrationGetOutput = {
@@ -3803,7 +3844,9 @@ export type IntegrationGetOutput = {
} }
export type IntegrationWellknownAddInput = { 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"] readonly url: { readonly url: string }["url"]
} }
@@ -3811,7 +3854,9 @@ export type IntegrationWellknownAddOutput = void
export type IntegrationConnectKeyInput = { export type IntegrationConnectKeyInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] 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 key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
} }
@@ -3820,7 +3865,9 @@ export type IntegrationConnectKeyOutput = void
export type IntegrationOauthConnectInput = { export type IntegrationOauthConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] 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: {
readonly methodID: string readonly methodID: string
readonly inputs: { readonly [x: string]: string } readonly inputs: { readonly [x: string]: string }
@@ -3852,7 +3899,9 @@ export type IntegrationOauthConnectOutput = {
export type IntegrationOauthStatusInput = { export type IntegrationOauthStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] 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 = { export type IntegrationOauthStatusOutput = {
@@ -3863,7 +3912,9 @@ export type IntegrationOauthStatusOutput = {
export type IntegrationOauthCompleteInput = { export type IntegrationOauthCompleteInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] 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"] readonly code?: { readonly code?: string | undefined }["code"]
} }
@@ -3872,14 +3923,18 @@ export type IntegrationOauthCompleteOutput = void
export type IntegrationOauthCancelInput = { export type IntegrationOauthCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] 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 IntegrationOauthCancelOutput = void
export type IntegrationCommandConnectInput = { export type IntegrationCommandConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"] 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 methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"] readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
} }
@@ -3892,7 +3947,9 @@ export type IntegrationCommandConnectOutput = {
export type IntegrationCommandStatusInput = { export type IntegrationCommandStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] 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 = { export type IntegrationCommandStatusOutput = {
@@ -3903,13 +3960,17 @@ export type IntegrationCommandStatusOutput = {
export type IntegrationCommandCancelInput = { export type IntegrationCommandCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] 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 IntegrationCommandCancelOutput = void
export type McpListInput = { 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 = { export type McpListOutput = {
@@ -3919,7 +3980,9 @@ export type McpListOutput = {
export type McpAddInput = { export type McpAddInput = {
readonly server: { readonly server: string }["server"] 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: {
readonly config: readonly config:
| { | {
@@ -3955,27 +4018,35 @@ export type McpAddOutput = void
export type McpRemoveInput = { export type McpRemoveInput = {
readonly server: { readonly server: string }["server"] 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 McpRemoveOutput = void
export type McpConnectInput = { export type McpConnectInput = {
readonly server: { readonly server: string }["server"] 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 McpConnectOutput = void
export type McpDisconnectInput = { export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"] 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 McpDisconnectOutput = void
export type McpResourceCatalogInput = { 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 = { export type McpResourceCatalogOutput = {
@@ -3985,7 +4056,9 @@ export type McpResourceCatalogOutput = {
export type CredentialUpdateInput = { export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] 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"] readonly label: { readonly label: string }["label"]
} }
@@ -3993,7 +4066,9 @@ export type CredentialUpdateOutput = void
export type CredentialRemoveInput = { export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] 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 export type CredentialRemoveOutput = void
@@ -4001,20 +4076,26 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project> export type ProjectListOutput = Array<Project>
export type ProjectCurrentInput = { 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 ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = { export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"] 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 ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = { 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 = { export type FormRequestListOutput = {
@@ -4868,7 +4949,9 @@ export type FormCancelInput = {
export type FormCancelOutput = void export type FormCancelOutput = void
export type PermissionRequestListInput = { 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 = { export type PermissionRequestListOutput = {
@@ -4974,7 +5057,9 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void export type PermissionReplyOutput = void
export type FileReadInput = { 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 readonly path: string
} }
@@ -4982,11 +5067,11 @@ export type FileReadOutput = globalThis.Uint8Array
export type FileListInput = { export type FileListInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined readonly path?: string | undefined
}["location"] }["location"]
readonly path?: { readonly path?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined readonly path?: string | undefined
}["path"] }["path"]
} }
@@ -4998,25 +5083,25 @@ export type FileListOutput = {
export type FileFindInput = { export type FileFindInput = {
readonly location?: { readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["location"] }["location"]
readonly query: { readonly query: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["query"] }["query"]
readonly type?: { readonly type?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["type"] }["type"]
readonly limit?: { readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string readonly query: string
readonly type?: "file" | "directory" | undefined readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
@@ -5029,7 +5114,9 @@ export type FileFindOutput = {
} }
export type CommandListInput = { 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 = { export type CommandListOutput = {
@@ -5038,7 +5125,9 @@ export type CommandListOutput = {
} }
export type SkillListInput = { 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 = { export type SkillListOutput = {
@@ -5049,7 +5138,9 @@ export type SkillListOutput = {
export type EventSubscribeOutput = V2Event export type EventSubscribeOutput = V2Event
export type PtyListInput = { 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 = { export type PtyListOutput = {
@@ -5058,7 +5149,9 @@ export type PtyListOutput = {
} }
export type PtyCreateInput = { 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?: {
readonly command?: string readonly command?: string
readonly args?: ReadonlyArray<string> readonly args?: ReadonlyArray<string>
@@ -5103,7 +5196,9 @@ export type PtyCreateOutput = {
export type PtyGetInput = { export type PtyGetInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] 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 = { export type PtyGetOutput = {
@@ -5113,7 +5208,9 @@ export type PtyGetOutput = {
export type PtyUpdateInput = { export type PtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] 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?: {
readonly title?: string readonly title?: string
readonly size?: { readonly rows: number; readonly cols: number } readonly size?: { readonly rows: number; readonly cols: number }
@@ -5128,13 +5225,17 @@ export type PtyUpdateOutput = {
export type PtyRemoveInput = { export type PtyRemoveInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"] 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 PtyRemoveOutput = void
export type ShellListInput = { 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 = { export type ShellListOutput = {
@@ -5143,7 +5244,9 @@ export type ShellListOutput = {
} }
export type ShellCreateInput = { 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: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
@@ -5177,7 +5280,9 @@ export type ShellCreateOutput = {
export type ShellGetInput = { export type ShellGetInput = {
readonly id: { readonly id: string }["id"] 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 = { export type ShellGetOutput = {
@@ -5187,7 +5292,9 @@ export type ShellGetOutput = {
export type ShellTimeoutInput = { export type ShellTimeoutInput = {
readonly id: { readonly id: string }["id"] 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"] readonly timeout: { readonly timeout: number }["timeout"]
} }
@@ -5199,17 +5306,17 @@ export type ShellTimeoutOutput = {
export type ShellOutputInput = { export type ShellOutputInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { 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 cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["location"] }["location"]
readonly cursor?: { 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 cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["cursor"] }["cursor"]
readonly limit?: { 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 cursor?: number | undefined
readonly limit?: number | undefined readonly limit?: number | undefined
}["limit"] }["limit"]
@@ -5222,13 +5329,17 @@ export type ShellOutputOutput = {
export type ShellRemoveInput = { export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"] 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 ShellRemoveOutput = void
export type QuestionRequestListInput = { 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 = { export type QuestionRequestListOutput = {
@@ -5256,7 +5367,9 @@ export type QuestionRejectInput = {
export type QuestionRejectOutput = void export type QuestionRejectOutput = void
export type ReferenceListInput = { 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 = { export type ReferenceListOutput = {
@@ -5266,7 +5379,9 @@ export type ReferenceListOutput = {
export type ProjectCopyCreateInput = { export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"] 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 strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"] readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
@@ -5276,7 +5391,9 @@ export type ProjectCopyCreateOutput = ProjectCopyCopy
export type ProjectCopyRemoveInput = { export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"] 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 directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"] readonly force: { readonly directory: string; readonly force: boolean }["force"]
} }
@@ -5285,13 +5402,17 @@ export type ProjectCopyRemoveOutput = void
export type ProjectCopyRefreshInput = { export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"] 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 ProjectCopyRefreshOutput = void
export type VcsGetInput = { 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 = { export type VcsGetOutput = {
@@ -5300,7 +5421,9 @@ export type VcsGetOutput = {
} }
export type VcsStatusInput = { 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 = { export type VcsStatusOutput = {
@@ -5310,17 +5433,17 @@ export type VcsStatusOutput = {
export type VcsDiffInput = { export type VcsDiffInput = {
readonly location?: { 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 mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["location"] }["location"]
readonly mode: { 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 mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["mode"] }["mode"]
readonly context?: { 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 mode: "working" | "branch"
readonly context?: number | undefined readonly context?: number | undefined
}["context"] }["context"]
@@ -5334,7 +5457,9 @@ export type VcsDiffOutput = {
export type DebugLocationListOutput = Array<LocationRef> export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = { 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 DebugLocationEvictOutput = void
@@ -5345,7 +5470,9 @@ export type MigrationV1StatusOutput =
| { status: "error"; error: string } | { status: "error"; error: string }
export type WebsearchProvidersInput = { 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 = { export type WebsearchProvidersOutput = {
@@ -5354,7 +5481,9 @@ export type WebsearchProvidersOutput = {
} }
export type WebsearchQueryInput = { 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 query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"] readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
} }
@@ -5365,7 +5494,9 @@ export type WebsearchQueryOutput = {
} }
export type ConfigGetInput = { export type ConfigGetInput = {
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 ConfigGetOutput = Array<ConfigEntry> export type ConfigGetOutput = Array<ConfigEntry>
+2
View File
@@ -181,6 +181,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const page = yield* client.session.list({ limit: 10 }) const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active() const active = yield* client.session.active()
const created = yield* client.session.create({ const created = yield* client.session.create({
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+6 -2
View File
@@ -454,7 +454,11 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } }) const created = await client.session.create({
agent: "build",
model: { id: "claude", providerID: "anthropic" },
location: { directory: "/tmp/project" },
})
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -528,7 +532,7 @@ test("middleware errors remain declared client errors", async () => {
}) })
try { try {
await client.session.create({}) await client.session.create({ agent: "build", model: { id: "claude", providerID: "anthropic" } })
throw new Error("Expected request to fail") throw new Error("Expected request to fail")
} catch (error) { } catch (error) {
expect(isUnauthorizedError(error)).toBe(true) expect(isUnauthorizedError(error)).toBe(true)
+2 -1
View File
@@ -1021,12 +1021,13 @@ describe("OpenAPI.fromSpec", () => {
await Effect.runPromise( await Effect.runPromise(
location location
.execute({ location: { directory: "/tmp" } }) .execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)), .pipe(Effect.provide(client.layer)),
) )
const url = new URL(client.requests[0]!.url) const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp") 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 () => { test("serializes supported simple and form parameter shapes", async () => {
+105 -70
View File
@@ -1,11 +1,15 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd", "id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"prevIds": [ "prevIds": [
"4d283c23-533f-4b87-9aa4-218f7fd36bca" "f14a9b18-8207-487e-a3d3-227e629ba9ad"
], ],
"ddl": [ "ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{ {
"name": "account_state", "name": "account_state",
"entityType": "tables" "entityType": "tables"
@@ -71,8 +75,84 @@
"entityType": "tables" "entityType": "tables"
}, },
{ {
"name": "workspace", "type": "text",
"entityType": "tables" "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": "integer", "type": "integer",
@@ -1305,63 +1385,18 @@
"table": "session_v2" "table": "session_v2"
}, },
{ {
"type": "text", "columns": [
"notNull": false, "project_id"
"autoincrement": false, ],
"default": null, "tableTo": "project",
"generated": null, "columnsTo": [
"name": "id", "id"
"entityType": "columns", ],
"table": "workspace" "onUpdate": "NO ACTION",
}, "onDelete": "CASCADE",
{ "nameExplicit": false,
"type": "text", "name": "fk_workspace_project_id_project_id_fk",
"notNull": true, "entityType": "fks",
"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",
"table": "workspace" "table": "workspace"
}, },
{ {
@@ -1529,6 +1564,15 @@
"entityType": "pks", "entityType": "pks",
"table": "instruction_entry" "table": "instruction_entry"
}, },
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{ {
"columns": [ "columns": [
"id" "id"
@@ -1646,15 +1690,6 @@
"table": "session_v2", "table": "session_v2",
"entityType": "pks" "entityType": "pks"
}, },
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{ {
"columns": [ "columns": [
{ {
+6 -3
View File
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
} }
function expandHome(resource: string, home: string) { function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home if (resource === "~") return home
if (resource === "$HOME") return home if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5) const relative = resource.startsWith("~/")
if (resource.startsWith("$HOME\\")) return home + resource.slice(5) ? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource return resource
} }
@@ -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
@@ -42,7 +42,5 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"), import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"), import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806150823_remove_workspace"),
import("./migration/20260806150824_add_workspace"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150823_remove_workspace",
up(tx) {
return Effect.gen(function* () {
// Legacy control-plane workspace references would otherwise route these
// sessions to hosted graphs whose workspace rows no longer exist.
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
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: "20260806150824_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 { export default {
up(tx) { up(tx) {
return Effect.gen(function* () { 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(` yield* tx.run(`
CREATE TABLE \`account_state\` ( CREATE TABLE \`account_state\` (
\`id\` integer PRIMARY KEY, \`id\` integer PRIMARY KEY,
@@ -203,16 +216,6 @@ export default {
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`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 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(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run( yield* tx.run(
+47 -207
View File
@@ -1,49 +1,23 @@
export * as FileMutation from "./file-mutation" export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Formatter } from "./formatter"
import { WorkspaceEnvironment } from "./workspace/environment"
export interface Target { export interface Target {
readonly canonical: string readonly canonical: string
/** Lexical path for entry operations; remove unlinks the name, not the referent. */
readonly absolute: string
readonly resource: string readonly resource: string
} }
/** Seam-owned absence so tools never see backend error vocabularies. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FileMutation.NotFoundError", {
path: Schema.String,
}) {}
/** The target resolved to a directory where a file operation was required. */
export class NotAFileError extends Schema.TaggedErrorClass<NotAFileError>()("FileMutation.NotAFileError", {
path: Schema.String,
}) {}
/** A move wrote its destination but failed to remove the source. */
export class MoveIncompleteError extends Schema.TaggedErrorClass<MoveIncompleteError>()(
"FileMutation.MoveIncompleteError",
{
from: Schema.String,
to: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export interface WriteInput { export interface WriteInput {
readonly target: Target readonly target: Target
readonly content: string readonly content: string | Uint8Array
} }
export interface MoveInput { export interface TextWriteInput {
readonly from: Target readonly target: Target
readonly to: Target
/** Text for the destination; may differ from the source content. */
readonly content: string readonly content: string
} }
@@ -52,208 +26,74 @@ export interface WriteResult {
readonly target: string readonly target: string
readonly resource: string readonly resource: string
readonly existed: boolean readonly existed: boolean
/** Final text on disk after BOM handling and formatting. */
readonly content: string
} }
export interface Interface { export interface Interface {
/** Read a text file with the BOM stripped. BOM handling stays inside the seam. */
readonly read: (target: Target) => Effect.Effect<string, NotFoundError | NotAFileError | FSUtil.Error>
/**
* Write logical text while retaining an existing UTF-8 BOM and emitting at
* most one BOM. Runs configured formatters where the files live and reports
* the final text.
*/
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error> readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
* Write `content` to `to` with write semantics, then remove `from`. The readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
* destination inherits the source's BOM when it has none of its own.
*/
readonly move: (input: MoveInput) => Effect.Effect<WriteResult, MoveIncompleteError | FSUtil.Error>
readonly remove: (target: Target) => Effect.Effect<void, NotFoundError | FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
/** Normalize model-provided text to the BOM-free representation tools consume. */
export const normalizeText = (content: string) => Bom.split(content).text
/**
* Backend primitives the shared seam orchestrates. Errors arrive already in
* the seam vocabulary so tool-level messages stay uniform across backends.
*/
interface Backend {
/** Full file bytes, or undefined when the path does not exist. */
readonly readOptional: (path: string) => Effect.Effect<Uint8Array | undefined, FSUtil.Error>
/** Classifies read failures; absence and errors report false. */
readonly isDirectory: (path: string) => Effect.Effect<boolean>
/** Creates parent directories, matching FSUtil.writeWithDirs. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, FSUtil.Error>
/** Unlink the lexical path; a symlink is removed, never its referent. */
readonly remove: (path: string) => Effect.Effect<void, NotFoundError | FSUtil.Error>
/** Format after write and report the final text, or undefined when no formatter applies. */
readonly format?: (path: string, bom: boolean) => Effect.Effect<string | undefined, FSUtil.Error>
}
const encoder = new TextEncoder()
/** /**
* Serialize file changes by canonical target. Conditional writes compare and * Serialize file changes by canonical target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do * write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content. * not overwrite changes made from the same stale content.
*/ */
const make = (backend: Backend): Interface => {
const locks = KeyedMutex.makeUnsafe<string>()
// Locks are acquired in sorted canonical order so multi-target operations
// cannot deadlock.
const withTargetLocks =
(...targets: readonly Target[]) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
[...new Set(targets.map((target) => target.canonical))]
.sort()
.reduceRight((locked, key) => locks.withLock(key)(locked), Effect.uninterruptible(effect))
// Happy-path reads are one operation; only the failure path checks whether
// the target is a directory.
const read = Effect.fn("FileMutation.read")((target: Target) =>
Effect.gen(function* () {
const content = yield* backend
.readOptional(target.canonical)
.pipe(
Effect.catch((error) =>
backend
.isDirectory(target.canonical)
.pipe(
Effect.flatMap((directory) =>
Effect.fail(directory ? new NotAFileError({ path: target.canonical }) : error),
),
),
),
)
if (content === undefined) return yield* new NotFoundError({ path: target.canonical })
return Bom.fromBytes(content).text
}),
)
const writeText = (target: Target, content: string, inheritedBom: boolean) =>
Effect.gen(function* () {
const next = Bom.split(content)
const current = yield* backend.readOptional(target.canonical)
const bom = Boolean(current && Bom.has(current)) || inheritedBom || next.bom
yield* backend.write(target.canonical, encoder.encode(Bom.join(next.text, bom)))
// Formatters may rewrite the file, so the backend re-syncs the BOM and
// reports the final text.
const formatted = backend.format ? yield* backend.format(target.canonical, bom) : undefined
return {
operation: "write" as const,
target: target.canonical,
resource: target.resource,
existed: current !== undefined,
content: formatted ?? next.text,
}
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLocks(input.target)(writeText(input.target, input.content, false)),
)
const move = Effect.fn("FileMutation.move")((input: MoveInput) =>
withTargetLocks(
input.from,
input.to,
)(
Effect.gen(function* () {
const source = yield* backend.readOptional(input.from.canonical)
const result = yield* writeText(input.to, input.content, Boolean(source && Bom.has(source)))
yield* backend
.remove(input.from.absolute)
.pipe(
Effect.mapError(
(cause) => new MoveIncompleteError({ from: input.from.canonical, to: input.to.canonical, cause }),
),
)
return result
}),
),
)
const remove = Effect.fn("FileMutation.remove")((target: Target) =>
withTargetLocks(target)(backend.remove(target.absolute)),
)
return Service.of({ read, write, move, remove })
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service const locks = KeyedMutex.makeUnsafe<string>()
return make({ const withTargetLock =
readOptional: (path) => (target: Target) =>
fs.readFile(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))), <A, E, R>(effect: Effect.Effect<A, E, R>) =>
isDirectory: (path) => locks.withLock(target.canonical)(Effect.uninterruptible(effect))
fs.stat(path).pipe(
Effect.map((info) => info.type === "Directory"), const writeResult = (target: Target, existed: boolean): WriteResult => ({
Effect.catch(() => Effect.succeed(false)), operation: "write",
), target: target.canonical,
write: (path, content) => fs.writeWithDirs(path, content), resource: target.resource,
remove: (path) => existed,
fs.remove(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => new NotFoundError({ path }))),
format: (path, bom) =>
formatter
.file(path)
.pipe(Effect.flatMap((formatted) => (formatted ? Bom.syncFile(fs, path, bom) : Effect.succeed(undefined)))),
}) })
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* fs.exists(input.target.canonical)
yield* fs.writeWithDirs(input.target.canonical, input.content)
return writeResult(input.target, existed)
}),
),
)
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.canonical,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ write, writeTextPreservingBom })
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Formatter.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Same seam through WorkspaceEnvironment.Files. Absence stays typed; other
// environment failures surface as filesystem errors. No formatting: formatters
// are host binaries and cannot run against provider paths until an environment
// formatter runtime exists.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return make({
readOptional: (path) =>
env.files.read(path).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.mapError(WorkspaceEnvironment.toFileSystemError("read")),
),
isDirectory: (path) =>
env.files.stat(path).pipe(
Effect.map((info) => info.type === "Directory"),
Effect.catch(() => Effect.succeed(false)),
),
write: (path, content) =>
env.files.write(path, content).pipe(Effect.mapError(WorkspaceEnvironment.toFileSystemError("write"))),
remove: (path) =>
env.files
.remove(path)
.pipe(
Effect.mapError((error) =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? new NotFoundError({ path })
: WorkspaceEnvironment.toFileSystemError("remove")(error),
),
),
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
/** /**
* Deferred until the corresponding integrations exist. * Deferred until the corresponding integrations exist.
*/ */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists. // TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists. // TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists. // TODO: Notify LSP and collect diagnostics after LSP runtime exists.
+45 -244
View File
@@ -7,9 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location" import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema" import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem" import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
import { WorkspaceEnvironment } from "./workspace/environment"
import { Ripgrep } from "./ripgrep"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem" export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({ export const ReadInput = Schema.Struct({
@@ -34,24 +32,7 @@ export type ListInput = typeof ListInput.Type
export { FindInput } export { FindInput }
export const DEFAULT_SEARCH_LIMIT = 100 export const DEFAULT_SEARCH_LIMIT = 100
/** One second of headroom over the hosted provider-side ripgrep kill. */ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export const DEFAULT_SEARCH_TIMEOUT_MS = (Ripgrep.HOSTED_KILL_TIMEOUT_SECONDS + 1) * 1_000
/** Applies the shared search budget, failing with a search-scoped message. */
export const searchTimeout =
<F>(fail: (message: string) => F) =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | F, R> =>
effect.pipe(
Effect.timeoutOrElse({
duration: DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
fail(
`Search timed out after ${DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
),
),
}),
)
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({ export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String, pattern: Schema.String,
@@ -66,251 +47,71 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
limit: Schema.optionalKey(PositiveInt), limit: Schema.optionalKey(PositiveInt),
}) {} }) {}
export interface SearchTarget {
readonly canonical: string
readonly absolute: string
}
export interface GlobSearchInput {
readonly target: SearchTarget
readonly pattern: string
readonly limit: number
}
export interface GrepSearchInput {
readonly target: SearchTarget
readonly pattern: string
readonly include?: string
readonly limit: number
}
export class SearchPathError extends Schema.TaggedErrorClass<SearchPathError>()("FileSystem.SearchPathError", {
path: Schema.String,
reason: Schema.Literals(["not_found", "not_directory"]),
}) {}
export class SearchError extends Schema.TaggedErrorClass<SearchError>()("FileSystem.SearchError", {
cause: Schema.Defect(),
}) {}
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()(
"FileSystem.InvalidPatternError",
{
pattern: Schema.String,
message: Schema.String,
},
) {}
const mapSearchError = (error: unknown) =>
error instanceof SearchPathError || error instanceof SearchError ? error : new SearchError({ cause: error })
const mapGrepError = (error: unknown) =>
error instanceof Ripgrep.InvalidPatternError
? new InvalidPatternError({ pattern: error.pattern, message: error.message })
: mapSearchError(error)
export const Event = FileSystem.Event export const Event = FileSystem.Event
/** Directories first, then lexicographic; shared by every entry listing. */
export const compareEntries = (a: Entry, b: Entry) =>
a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1
export interface Interface { export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]> readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]> readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly glob: (input: GlobSearchInput) => Effect.Effect<Entry[], SearchPathError | SearchError>
readonly grep: (input: GrepSearchInput) => Effect.Effect<Match[], SearchPathError | SearchError | InvalidPatternError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
const makeSearch = (
location: Location.Interface,
ripgrep: Ripgrep.Interface,
paths: Pick<typeof path.posix, "resolve" | "relative" | "dirname" | "basename">,
stat: (target: SearchTarget) => Effect.Effect<{ readonly type: string }, SearchPathError | SearchError>,
) => ({
glob: Effect.fn("FileSystem.glob")(function* (input: GlobSearchInput) {
if ((yield* stat(input.target)).type !== "Directory")
return yield* new SearchPathError({ path: input.target.absolute, reason: "not_directory" })
return yield* ripgrep
.glob({
cwd: input.target.canonical,
pattern: input.pattern,
limit: input.limit,
})
.pipe(
Effect.map((entries) =>
entries.map((entry) =>
Entry.make({
...entry,
path: RelativePath.make(
paths.relative(location.directory, paths.resolve(input.target.absolute, entry.path)),
),
}),
),
),
)
}, Effect.mapError(mapSearchError)),
grep: Effect.fn("FileSystem.grep")(function* (input: GrepSearchInput) {
const info = yield* stat(input.target)
const file = info.type === "File"
const cwd = file ? paths.dirname(input.target.absolute) : input.target.canonical
const root = file ? paths.dirname(input.target.absolute) : input.target.absolute
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: file ? paths.basename(input.target.absolute) : undefined,
include: input.include,
limit: input.limit,
})
.pipe(
Effect.map((matches) =>
matches.map((match) =>
Match.make({
...match,
entry: Entry.make({
...match.entry,
path: RelativePath.make(paths.relative(location.directory, paths.resolve(root, match.entry.path))),
}),
}),
),
),
)
}, Effect.mapError(mapGrepError)),
})
/**
* Entry primitives the shared read/list implementations consume. Escape
* failures and type mismatches are defects, matching route behavior.
*/
interface EntryBackend {
readonly paths: Pick<typeof path.posix, "resolve" | "relative" | "join" | "sep">
readonly contains: (parent: string, child: string) => boolean
readonly realPath: (target: string) => Effect.Effect<string>
/** Reads one file; non-file targets die. */
readonly readFile: (real: string) => Effect.Effect<Uint8Array>
/** Lists one directory; non-directory targets die. */
readonly listDir: (real: string) => Effect.Effect<readonly FSUtil.DirEntry[]>
}
const makeEntries = (location: Location.Interface, root: string, backend: EntryBackend) => {
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = backend.paths.resolve(location.directory, input ?? ".")
if (!backend.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* backend.realPath(absolute)
if (!backend.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real }
})
return {
read: Effect.fn("FileSystem.read")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
return {
content: yield* backend.readFile(target.real),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const items = yield* backend.listDir(target.real)
return items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = backend.paths.join(target.absolute, item.name)
const relative = backend.paths.relative(location.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? backend.paths.sep : "")),
type: item.type,
}),
]
})
.sort(compareEntries)
}),
}
}
const baseLayer = Layer.effect( const baseLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const search = yield* FileSystemSearch.Service const search = yield* FileSystemSearch.Service
const ripgrep = yield* Ripgrep.Service
const searches = makeSearch(location, ripgrep, path, (target) =>
fs.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
),
Effect.mapError(mapSearchError),
),
)
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie) const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const entries = makeEntries(location, root, { const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
paths: path, const absolute = path.resolve(location.directory, input ?? ".")
contains: FSUtil.contains, if (!FSUtil.contains(location.directory, absolute))
realPath: (target) => fs.realPath(target).pipe(Effect.orDie), return yield* Effect.die(new Error("Path escapes the location"))
readFile: (real) => const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
Effect.gen(function* () { if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
const info = yield* fs.stat(real).pipe(Effect.orDie) return { absolute, real, directory: location.directory, root }
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) })
return yield* fs.readFile(real).pipe(Effect.orDie) return Service.of({
}), find: search.find,
listDir: (real) => read: Effect.fn("FileSystem.read")(function* (input) {
Effect.gen(function* () { const target = yield* resolve(input.path)
const info = yield* fs.stat(real).pipe(Effect.orDie) const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
return yield* fs.readDirectoryEntries(real).pipe(Effect.orDie) return {
}), content: yield* fs.readFile(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
Entry.make({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
}) })
return Service.of({ find: search.find, ...searches, ...entries })
}), }),
) )
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer: baseLayer, layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node, Ripgrep.node], 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 ripgrep = yield* Ripgrep.Service
const searches = makeSearch(location, ripgrep, path.posix, (target) =>
env.files.stat(target.canonical).pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () =>
Effect.fail(new SearchPathError({ path: target.absolute, reason: "not_found" })),
),
Effect.mapError(mapSearchError),
),
)
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const entries = makeEntries(location, root, {
paths: path.posix,
contains: FSUtil.containsPosix,
realPath: (target) => env.files.realPath(target).pipe(Effect.orDie),
readFile: (real) => env.files.read(real).pipe(Effect.orDie),
listDir: (real) => env.files.list(real).pipe(Effect.orDie),
})
return Service.of({
find: () => Effect.logWarning("find is not supported for hosted locations yet").pipe(Effect.as([])),
...searches,
...entries,
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node, Location.node, Ripgrep.node],
}) })
+1
View File
@@ -10,6 +10,7 @@ const prefixes = {
part: "prt", part: "prt",
pty: "pty", pty: "pty",
tool: "tool", tool: "tool",
workspace: "wrk",
} as const } as const
export function ascending(prefix: keyof typeof prefixes, given?: string) { export function ascending(prefix: keyof typeof prefixes, given?: string) {
+37 -113
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location" import { Location } from "./location"
import { Project } from "./project" import { Project } from "./project"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"]) export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type export type Kind = typeof Kind.Type
@@ -26,12 +25,12 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", { export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String, path: Schema.String,
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]), reason: Schema.Literal("non_directory_ancestor"),
}) {} }) {}
export interface ExternalDirectoryAuthorization { export interface ExternalDirectoryAuthorization {
readonly action: "external_directory" readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */ /** Canonical existing directory used as the external approval boundary. */
readonly directory: string readonly directory: string
/** `external_directory` permission resource. */ /** `external_directory` permission resource. */
readonly resource: string readonly resource: string
@@ -47,13 +46,7 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
export interface Target { export interface Target {
/** Canonical existing path, or missing path below a canonical directory. */ /** Canonical existing path, or missing path below a canonical directory. */
readonly canonical: string readonly canonical: string
/** /** Permission resource: Location-relative for internal paths, canonical for external paths. */
* Lexical resolved path before symlink canonicalization. Reads and writes
* address the referent (canonical); entry operations like remove address
* the name itself.
*/
readonly absolute: string
/** Permission resource: Location-relative for internal paths, lexical absolute for external paths. */
readonly resource: string readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization readonly externalDirectory?: ExternalDirectoryAuthorization
} }
@@ -81,56 +74,9 @@ interface ResolvedPath {
| "Socket" | "Socket"
| "Unknown" | "Unknown"
readonly directory: string readonly directory: string
readonly lexicalDirectory: string
} }
/** const slash = (value: string) => value.replaceAll("\\", "/")
* Resolution primitives the shared ancestor walk uses; absence reports
* undefined so the walk owns the missing-path vocabulary.
*/
interface WalkBackend<E> {
readonly paths: Pick<path.PlatformPath, "dirname" | "resolve" | "relative">
readonly realPathOptional: (path: string) => Effect.Effect<string | undefined, E>
readonly statOptional: (path: string) => Effect.Effect<{ readonly type: ResolvedPath["type"] } | undefined, E>
}
/**
* Canonicalize an existing path, or resolve a missing path below its nearest
* existing ancestor directory.
*/
const resolvePathWith = <E>(backend: WalkBackend<E>) =>
Effect.fnUntraced(function* (absolute: string) {
const existing = yield* backend.realPathOptional(absolute)
if (existing !== undefined) {
const info = yield* backend.statOptional(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 : backend.paths.dirname(existing),
lexicalDirectory: info.type === "Directory" ? absolute : backend.paths.dirname(absolute),
} satisfies ResolvedPath
}
let anchor = backend.paths.dirname(absolute)
while (true) {
const canonical = yield* backend.realPathOptional(anchor)
if (canonical !== undefined) {
const info = yield* backend.statOptional(canonical)
if (info === undefined || info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: backend.paths.resolve(canonical, backend.paths.relative(anchor, absolute)),
directory: canonical,
lexicalDirectory: anchor,
} satisfies ResolvedPath
}
const parent = backend.paths.dirname(anchor)
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
anchor = parent
}
})
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@@ -142,10 +88,34 @@ const layer = Layer.effect(
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
} }
const resolvePath = resolvePathWith({ const resolvePath = Effect.fnUntraced(function* (absolute: string) {
paths: path, const existing = yield* notFound(fs.realPath(absolute))
realPathOptional: (target) => notFound(fs.realPath(target)), if (existing !== undefined) {
statOptional: (target) => notFound(fs.stat(target)), const info = yield* fs.stat(existing)
return {
canonical: existing,
type: info.type,
directory: info.type === "Directory" ? existing : path.dirname(existing),
} satisfies ResolvedPath
}
let anchor = path.dirname(absolute)
while (true) {
const canonical = yield* notFound(fs.realPath(anchor))
if (canonical !== undefined) {
const info = yield* fs.stat(canonical)
if (info.type !== "Directory") {
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
}
return {
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
directory: canonical,
} satisfies ResolvedPath
}
const parent = path.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 resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
@@ -156,21 +126,19 @@ const layer = Layer.effect(
const resolved = yield* resolvePath(absolute) const resolved = yield* resolvePath(absolute)
const external = !lexicallyInternal const external = !lexicallyInternal
const resource = external const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
? FSUtil.slash(absolute) const externalDirectory =
: FSUtil.slash(path.relative(location.directory, absolute) || ".") input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
const externalDirectory = resolved.lexicalDirectory const externalResource = slash(path.join(externalDirectory, "*"))
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
return { return {
canonical: resolved.canonical, canonical: resolved.canonical,
absolute,
resource, resource,
externalDirectory: external externalDirectory: external
? { ? {
action: "external_directory", action: "external_directory",
directory: externalDirectory, directory: externalDirectory,
resource: externalResource, resource: externalResource,
save: FSUtil.slash( save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"), path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
), ),
} }
@@ -187,47 +155,3 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie), layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node], 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
// Canonicalized even when the Location sits on the workspace root: the
// environment does not guarantee a canonical directory (local fakes root
// at symlinked temp paths).
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolvePath = resolvePathWith({
paths: path.posix,
realPathOptional: (target) => WorkspaceEnvironment.optional(env.files.realPath(target)),
statOptional: (target) => WorkspaceEnvironment.optional(env.files.stat(target)),
})
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)
if (!FSUtil.containsPosix(root, resolved.canonical))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
return {
canonical: resolved.canonical,
absolute,
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 -28
View File
@@ -11,7 +11,6 @@ import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter" import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
import { Ripgrep } from "./ripgrep"
import { Generate } from "./generate" import { Generate } from "./generate"
import { Form } from "./form" import { Form } from "./form"
import { Image } from "./image" import { Image } from "./image"
@@ -48,8 +47,6 @@ import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem" import { ReadToolFileSystem } from "./tool/read-filesystem"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Vcs } from "./vcs" import { Vcs } from "./vcs"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WorkspaceEnvironment } from "./workspace/environment"
export { LocationServiceMap } from "./location-service-map" export { LocationServiceMap } from "./location-service-map"
@@ -107,27 +104,6 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
export type LocationServices = LayerNode.Output<typeof locationServices> export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices> export type LocationError = LayerNode.Error<typeof locationServices>
/**
* 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. Exported so tests can verify that every
* Location-path-consuming service is environment-backed in hosted graphs.
*/
export function hostedReplacements(ref: Location.Ref, workspaceID: Workspace.ID): LayerNode.Replacements {
return [
[Location.node, Location.hostedBoundNode(ref, workspaceID)],
[Config.node, Config.configured({ project: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false })],
[WorkspaceEnvironment.node, WorkspaceEnvironment.hostedNode(workspaceID)],
[Ripgrep.node, Ripgrep.hostedNode],
[FileSystem.node, FileSystem.hostedNode],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[ReadToolFileSystem.node, ReadToolFileSystem.hostedNode],
[Shell.node, Shell.hostedNode],
]
}
export function buildLocationServiceMap( export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [], replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> { ): Layer.Layer<LocationServiceMap.Service> {
@@ -142,10 +118,7 @@ export function buildLocationServiceMap(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const startedAt = performance.now() const startedAt = performance.now()
const workspaceID = ref.workspaceID const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const allReplacements = replacements.concat(
workspaceID ? hostedReplacements(ref, workspaceID) : [[Location.node, Location.boundNode(ref)]],
)
// Apply replacements during hoist, not afterward: replacements can // Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on // introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice // Project), and the hoist walk is the only pass that can still slice
-32
View File
@@ -1,9 +1,6 @@
import path from "path"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location" import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Project } from "./project" import { Project } from "./project"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node" import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
@@ -17,12 +14,6 @@ export interface Interface extends Info {
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
/**
* Path rules for a Location's directory. Hosted directories live in the
* provider filesystem: posix semantics regardless of the host platform.
*/
export const paths = (location: Pick<Info, "workspaceID">) => (location.workspaceID ? path.posix : path)
export const node = LayerNode.unbound(Service, tags.values.location) export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) => const layer = (ref: Ref) =>
@@ -46,26 +37,3 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref), layer: layer(ref),
deps: [Project.node], 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],
})
+5 -5
View File
@@ -340,12 +340,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
hook: (name, callback) => hooks.register("session", name, callback), hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input.id,
title: input?.title, title: input.title,
agent: input?.agent, agent: input.agent,
model: input?.model, model: input.model,
location: location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), input.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}), }),
get: (input) => runtime.session.get(input.sessionID), get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt, prompt: runtime.session.prompt,
+3
View File
@@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission" import { Permission } from "../permission"
import { Reference } from "../reference" import { Reference } from "../reference"
import { WebSearch } from "../websearch" import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions" import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell" import { Shell } from "../shell"
import { Skill } from "../skill" import { Skill } from "../skill"
@@ -88,6 +89,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const read = yield* ReadToolFileSystem.Service const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
@@ -119,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(ReadToolFileSystem.Service, read), Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference), Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch), Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions), Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell), Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill), Context.make(Skill.Service, skill),
+15 -19
View File
@@ -269,25 +269,21 @@ export function fromPromise(plugin: Plugin) {
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create({
input === undefined id: input.id == null ? undefined : Session.ID.make(input.id),
? undefined agent: Agent.ID.make(input.agent),
: { model: model(input.model),
id: input.id == null ? undefined : Session.ID.make(input.id), location:
agent: input.agent == null ? undefined : Agent.ID.make(input.agent), input.location == null
model: input.model == null ? undefined : model(input.model), ? undefined
location: : Location.Ref.make({
input.location == null directory: AbsolutePath.make(input.location.directory),
? undefined workspaceID:
: Location.Ref.make({ input.location.workspaceID === undefined
directory: AbsolutePath.make(input.location.directory), ? undefined
workspaceID: : Workspace.ID.make(input.location.workspaceID),
input.location.workspaceID === undefined }),
? undefined }),
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
), ),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })), get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) => prompt: (input) =>
+2 -3
View File
@@ -282,9 +282,7 @@ const layer = Layer.effect(
}) })
const updates = Stream.merge( const updates = Stream.merge(
config.changes().pipe( config.changes().pipe(
Stream.filterEffect((update) => Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)), Stream.merge(Stream.fromPubSub(configuredChanges)),
), ),
bus.subscribe([Event.Updated, SdkPlugins.Updated]), bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -340,6 +338,7 @@ export const node = makeLocationNode({
Form.node, Form.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
Reference.node, Reference.node,
Ripgrep.node,
SessionInstructions.node, SessionInstructions.node,
Shell.node, Shell.node,
Skill.node, Skill.node,
-12
View File
@@ -44,18 +44,6 @@ export interface Resolved {
readonly vcs?: Vcs 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. // Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* ( export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface, fs: FSUtil.Interface,
+5 -1
View File
@@ -1,5 +1,6 @@
export * as PtyTicket from "./ticket" export * as PtyTicket from "./ticket"
import { Workspace } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect" import { Cache, Context, Duration, Effect, Layer } from "effect"
@@ -13,6 +14,7 @@ export const ConnectToken = PtyTicket.ConnectToken
export type Scope = { export type Scope = {
readonly ptyID: PtyID readonly ptyID: PtyID
readonly directory?: string readonly directory?: string
readonly workspaceID?: Workspace.ID
} }
export interface Interface { export interface Interface {
@@ -23,7 +25,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {} export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) { 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 // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
+175 -246
View File
@@ -2,13 +2,11 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import type { Command } from "effect/unstable/process/ChildProcess"
import { Entry, Match } from "@opencode-ai/schema/filesystem" import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process" import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary" import { RipgrepBinary } from "./ripgrep/binary"
import { WorkspaceEnvironment } from "./workspace/environment"
/** /**
* Small core-owned ripgrep execution adapter. It deliberately exposes raw * Small core-owned ripgrep execution adapter. It deliberately exposes raw
@@ -20,13 +18,6 @@ import { WorkspaceEnvironment } from "./workspace/environment"
const ERROR_BYTES = 8 * 1024 const ERROR_BYTES = 8 * 1024
const MAX_SUBMATCHES = 100 const MAX_SUBMATCHES = 100
/**
* Provider-side kill for hosted searches, since hosted process kill is not
* implemented. Kept below FileSystem.DEFAULT_SEARCH_TIMEOUT_MS so the sandbox
* process dies before the caller's timeout fires.
*/
export const HOSTED_KILL_TIMEOUT_SECONDS = 29
const RawMatch = Schema.Struct({ const RawMatch = Schema.Struct({
type: Schema.Literal("match"), type: Schema.Literal("match"),
data: Schema.Struct({ data: Schema.Struct({
@@ -99,208 +90,184 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause
const isInvalidPattern = (stderr: string) => const isInvalidPattern = (stderr: string) =>
stderr.includes("regex parse error") || stderr.includes("error parsing regex") stderr.includes("regex parse error") || stderr.includes("error parsing regex")
const normalizeRelativePath = (value: string) =>
value
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
interface Backend {
readonly spawn: AppProcess.Interface["spawn"]
readonly awaitTruncated: boolean
readonly command: (input: {
readonly cwd: string
readonly args: string[]
readonly limit: number
readonly output: "lines" | "matches"
}) => Effect.Effect<Command, globalThis.Error>
}
const make = (backend: Backend) => {
const run = <A>(input: {
readonly cwd: string
readonly args: string[]
readonly limit: number
readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly output: "lines" | "matches"
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* backend.spawn(yield* backend.command(input))
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) {
if (backend.awaitTruncated)
yield* Effect.all([handle.exitCode, Fiber.join(stderrFiber)], { concurrency: "unbounded" })
return { items: rows.slice(0, input.limit), truncated, partial: false }
}
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
}
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
glob: (input) =>
run<string>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
output: "lines",
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) => Effect.succeed(normalizeRelativePath(line)),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
output: "lines",
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
return Effect.succeed(
Entry.make({
path: RelativePath.make(normalizeRelativePath(line)),
type: "file",
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
output: "matches",
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",
],
parse: (line) =>
decodeJsonRecord(line).pipe(
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
return Match.make({
entry: Entry.make({
path: RelativePath.make(normalizeRelativePath(match.path.text)),
type: "file",
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
}),
),
),
})
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const process = yield* AppProcess.Service const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service const binary = yield* RipgrepBinary.Service
return make({
spawn: (command) => process.spawn(command), const run = <A>(input: {
awaitTruncated: false, readonly cwd: string
command: (input) => readonly args: string[]
binary.filepath.pipe( readonly limit: number
Effect.map((executable) => readonly signal?: AbortSignal
ChildProcess.make(executable, input.args, { readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
cwd: input.cwd, readonly pattern?: string
extendEnv: true, readonly onItem?: (item: A) => Effect.Effect<void>
stdin: "ignore", }) => {
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > input.limit
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
const code = yield* handle.exitCode
const stderr = yield* Fiber.join(stderrFiber)
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
}
if (code !== 0 && code !== 1 && code !== 2) {
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
}
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
}),
)
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
return abortable.pipe(
Effect.mapError((cause) =>
cause instanceof Error || cause instanceof InvalidPatternError
? cause
: failure("ripgrep execution failed", cause),
),
)
}
return Service.of({
glob: (input) =>
run<string>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!**/.git/**",
".",
],
parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed(
Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<RawMatchData>({
...input,
args: [
"--no-config",
"--json",
"--hidden",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",
],
parse: (line) =>
decodeJsonRecord(line).pipe(
Effect.mapError((cause) => failure("Invalid ripgrep JSON output", cause)),
Effect.flatMap((json) => {
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match")
return Effect.succeed(undefined)
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({
entry: Entry.make({
path: RelativePath.make(relative),
type: "file",
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
}), }),
), ),
), ),
@@ -308,42 +275,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [RipgrepBinary.node, AppProcess.node] }) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
export const hostedNode = makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return make({
spawn: (command) => env.process.spawn(command),
awaitTruncated: true,
command: (input) =>
Effect.succeed(
ChildProcess.make(
env.shell.executable,
[
...env.shell.args(
input.output === "lines"
? `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | head -n "$limit"`
: `set -o pipefail; limit=$1; shift; timeout --signal=KILL ${HOSTED_KILL_TIMEOUT_SECONDS}s rg "$@" | awk -v limit="$limit" '{ print } /"type":"match"/ { if (++matches >= limit) exit }'`,
),
"opencode-ripgrep",
String(input.limit + 1),
...input.args,
],
{
cwd: input.cwd,
env: env.shell.environmentOverrides,
extendEnv: false,
detached: env.shell.detached,
stdin: "ignore",
},
),
),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+14 -28
View File
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session" 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 { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project" import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model" import { Model } from "./model"
import { Location } from "./location" import { Location } from "./location"
import { SessionMessage } from "./session/message" import { SessionMessage } from "./session/message"
@@ -26,8 +27,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error" import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { Workspace } from "./workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map" import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
@@ -58,9 +58,12 @@ import { fileURLToPath } from "url"
// - by project // - by project
// - by subpath // - by subpath
// - by workspace (home is special)
export { ListAnchor } export { ListAnchor }
const ListInputBase = { const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional), search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional), order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
@@ -150,9 +153,7 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{ readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[] readonly data: SessionSchema.Info[]
}> }>
readonly create: ( readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
input: CreateInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
readonly fork: ( readonly fork: (
input: ForkInput, input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError> ) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -198,6 +199,7 @@ export interface Interface {
readonly move: (input: { readonly move: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
directory: AbsolutePath directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError> }) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
@@ -233,7 +235,7 @@ export interface Interface {
id?: Event.ID id?: Event.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
command: string command: string
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError> }) => Effect.Effect<void, NotFoundError>
readonly skill: (input: { readonly skill: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -278,7 +280,6 @@ const layer = Layer.effect(
const db = database.db const db = database.db
const bus = yield* Bus.Service const bus = yield* Bus.Service
const projects = yield* Project.Service const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service const global = yield* Global.Service
const execution = yield* SessionExecution.Service const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
@@ -290,17 +291,6 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>() const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) 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 persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type const vcs = project.vcs?.type
return db return db
@@ -338,9 +328,7 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location const location = parent?.location ?? input.location
if (location === undefined) if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID")) return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = location.workspaceID const project = yield* projects.resolve(location.directory)
? yield* hostedProject(location.workspaceID, location.directory)
: yield* projects.resolve(location.directory)
yield* persistProject(project) yield* persistProject(project)
const projected = yield* bus const projected = yield* bus
.publish( .publish(
@@ -352,9 +340,7 @@ const layer = Layer.effect(
projectID: project.id, projectID: project.id,
parentID: input.parentID, parentID: input.parentID,
location, location,
subpath: RelativePath.make( subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
FSUtil.slash(Location.paths(location).relative(project.directory, location.directory)),
),
title: input.title, title: input.title,
agent: input.agent, agent: input.agent,
model: input.model model: input.model
@@ -441,6 +427,7 @@ const layer = Layer.effect(
const sortColumn = SessionTable.time_updated const sortColumn = SessionTable.time_updated
const conditions: SQL[] = [] const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) 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) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath)) if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
@@ -714,10 +701,10 @@ const layer = Layer.effect(
const expanded = const expanded =
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded)) const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
if (current.location.directory === directory) return
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory }) if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory) const project = yield* projects.resolve(directory)
yield* persistProject(project) yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) { if ((yield* execution.active).has(input.sessionID)) {
@@ -728,9 +715,9 @@ const layer = Layer.effect(
SessionEvent.Moved, SessionEvent.Moved,
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
location: Location.Ref.make({ directory }), location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id, projectID: project.id,
subpath: RelativePath.make(FSUtil.slash(path.relative(project.directory, directory))), subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
}, },
{ location: current.location }, { location: current.location },
) )
@@ -1026,7 +1013,6 @@ export const node = makeGlobalNode({
Database.node, Database.node,
Bus.node, Bus.node,
Project.node, Project.node,
Workspace.node,
SessionExecution.node, SessionExecution.node,
SessionStore.node, SessionStore.node,
LocationServiceMap.node, LocationServiceMap.node,
-14
View File
@@ -2,7 +2,6 @@ export * as SessionErrors from "./error"
import { Schema } from "effect" import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error" 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", { export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
messageID: SessionMessage.ID, messageID: SessionMessage.ID,
+10 -3
View File
@@ -7,16 +7,16 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model" import { Model } from "../model"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater" import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending" import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
import { InstructionState } from "./instruction-state" import { InstructionState } from "./instruction-state"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql" import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import { Slug } from "../util/slug" import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import type { SessionSchema } from "./schema" import type { SessionSchema } from "./schema"
import { Workspace } from "../workspace" import { WorkspaceTable } from "../control-plane/workspace.sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }> type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
@@ -376,6 +376,13 @@ const layer = Layer.effectDiscard(
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
if (!event.data.location.workspaceID) return
yield* db
.update(WorkspaceTable)
.set({ time_used: Date.now() })
.where(eq(WorkspaceTable.id, event.data.location.workspaceID))
.run()
.pipe(Effect.orDie)
}), }),
) )
yield* bus.project(SessionEvent.Moved, (event) => yield* bus.project(SessionEvent.Moved, (event) =>
@@ -386,7 +393,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory, directory: event.data.location.directory,
path: event.data.subpath, path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}), ...(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), time_updated: DateTime.toEpochMillis(event.created),
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
+269 -351
View File
@@ -6,14 +6,12 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer" import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell" import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process" import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config" import { Config } from "./config"
import { Bus } from "./bus" import { Bus } from "./bus"
import { Location } from "./location" import { Location } from "./location"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select" import { ShellSelect } from "./shell/select"
import { WorkspaceEnvironment } from "./workspace/environment"
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell" import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
import { PluginHooks } from "./plugin/hooks" import { PluginHooks } from "./plugin/hooks"
@@ -21,18 +19,6 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("She
id: Shell.ID, id: Shell.ID,
}) {} }) {}
export class InvalidCwdError extends Schema.TaggedErrorClass<InvalidCwdError>()("Shell.InvalidCwdError", {
path: Schema.String,
reason: Schema.Literals(["not_found", "not_directory", "unavailable"]),
cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
if (this.reason === "not_found") return `Working directory does not exist: ${this.path}`
if (this.reason === "not_directory") return `Working directory is not a directory: ${this.path}`
return `Unable to inspect working directory: ${this.path}`
}
}
// Exited processes stay observable (status, exit code, retained output) until removed explicitly. // Exited processes stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned commands do not accumulate unbounded state and output files. // Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25 const EXITED_LIMIT = 25
@@ -64,7 +50,7 @@ export interface Interface {
readonly create: <E = never, R = never>( readonly create: <E = never, R = never>(
input: Shell.CreateInput, input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>, before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | InvalidCwdError, R> ) => Effect.Effect<Shell.Info, E, R>
// Currently running commands only; exited shells are retained for get/output but excluded here. // Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]> readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError> readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -79,354 +65,286 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */ export const layer = (options?: ShellSelect.Options) => Layer.effect(
interface Backend { Service,
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]> Effect.gen(function* () {
/** Shell executable; re-evaluated per command so config changes apply. */ const bus = yield* Bus.Service
readonly shell: Effect.Effect<string> const location = yield* Location.Service
readonly args: (shell: string, command: string) => readonly string[] const config = yield* Config.Service
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */ const global = yield* Global.Service
readonly env: Readonly<Record<string, string | undefined>> const appProcess = yield* AppProcess.Service
readonly detached: boolean const hooks = yield* PluginHooks.Service
readonly validateCwd: (cwd: string) => Effect.Effect<void, InvalidCwdError> const context = yield* Effect.context()
} const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) => const outputDir = path.join(global.data, "shell", location.project.id)
Layer.effect( const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
Service, const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
Effect.gen(function* () { yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
const bus = yield* Bus.Service
const location = yield* Location.Service
const global = yield* Global.Service
const spawner = yield* backend
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id) yield* Effect.addFinalizer(() =>
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises")) Effect.gen(function* () {
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs")) for (const session of sessions.values()) {
yield* Effect.promise(() => mkdir(outputDir, { recursive: true })) if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Effect.addFinalizer(() => yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const name = () => spawner.shell.pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
} }
}) sessions.clear()
exitOrder.length = 0
const create = Effect.fn("Shell.create")(function* <E = never, R = never>( }),
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* spawner.shell,
env: {
...spawner.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
yield* spawner.validateCwd(invocation.cwd)
const id = Shell.ID.ascending()
const args = spawner.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: spawner.detached,
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
const validateCwdWith =
<E>(stat: (cwd: string) => Effect.Effect<{ readonly type: string }, E>, isNotFound: (error: E) => boolean) =>
(cwd: string) =>
stat(cwd).pipe(
Effect.mapError(
(error) =>
new InvalidCwdError({ path: cwd, reason: isNotFound(error) ? "not_found" : "unavailable", cause: error }),
),
Effect.flatMap((info) =>
info.type === "Directory"
? Effect.void
: Effect.fail(new InvalidCwdError({ path: cwd, reason: "not_directory" })),
),
) )
export const layer = (options?: ShellSelect.Options) => const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
layerWith( const session = sessions.get(id)
Effect.gen(function* () { if (!session) return yield* new NotFoundError({ id })
const config = yield* Config.Service return session
const appProcess = yield* AppProcess.Service })
const fs = yield* FSUtil.Service
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
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)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return { return {
spawn: (command) => appProcess.spawn(command), output: buffer.subarray(0, bytesRead).toString("utf8"),
shell: config cursor: start + bytesRead,
.entries() size: session.size,
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))), truncated: false,
args: ShellSelect.args, }
env: process.env, })
detached: process.platform !== "win32",
validateCwd: validateCwdWith( const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
(cwd) => fs.stat(cwd), input: Shell.CreateInput,
(error) => error.reason._tag === "NotFound", before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
), ) {
} satisfies Backend const invocation: ShellCreateBefore = {
}), command: input.command,
) cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
export function configured(options?: ShellSelect.Options) { export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, FSUtil.node, PluginHooks.node], deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
}) })
} }
export const node = configured() 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,
validateCwd: validateCwdWith(
(cwd) => env.files.stat(cwd),
(error) => error._tag === "WorkspaceEnvironment.NotFoundError",
),
} satisfies Backend
}),
),
deps: [Bus.node, Location.node, Global.node, WorkspaceEnvironment.node, PluginHooks.node],
})
+119 -100
View File
@@ -12,6 +12,8 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { fileDiff } from "./file-diff" import { fileDiff } from "./file-diff"
@@ -108,113 +110,130 @@ export const Plugin = {
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false, permission: "edit" }, name,
description: options: { codemode: false, permission: "edit" },
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.", description:
input: Input, "Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
output: Output, input: Input,
execute: (input, context) => { output: Output,
return Effect.gen(function* () { execute: (input, context) => {
const permissionSource = { return Effect.gen(function* () {
type: "tool" as const, const permissionSource = {
messageID: context.messageID, type: "tool" as const,
id: context.id, messageID: context.messageID,
} id: context.id,
if (input.oldString === input.newString) { }
return yield* new ToolFailure({ if (input.oldString === input.newString) {
message: "No changes to apply: oldString and newString are identical.", return yield* new ToolFailure({
}) message: "No changes to apply: oldString and newString are identical.",
} })
if (input.oldString === "") { }
return yield* new ToolFailure({ if (input.oldString === "") {
message: "oldString must not be empty. Use write to create or overwrite a file.", return yield* new ToolFailure({
}) message: "oldString must not be empty. Use write to create or overwrite a file.",
} })
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" }) const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory const external = target.externalDirectory
if (external) { if (external) {
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), ...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: permissionSource, source: permissionSource,
}) })
} }
const source = yield* files.read(target).pipe( const info = yield* fs.stat(target.canonical).pipe(
Effect.catchTag("FileMutation.NotFoundError", () => Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
), ),
Effect.catchTag("FileMutation.NotAFileError", () => )
Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })), if (info.type === "Directory") {
), return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
) }
const ending = source.includes(crlf) ? crlf : "\n" const original = yield* Bom.readFile(fs, target.canonical)
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) const source = original.text
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending) const ending = source.includes(crlf) ? crlf : "\n"
const exact = findOccurrences(source, oldString) const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
// These one-to-one mappings preserve offsets into the original source. const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
const unicode = const exact = findOccurrences(source, oldString)
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString)) // These one-to-one mappings preserve offsets into the original source.
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString) const unicode =
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
const replacements = matches.length const trailing =
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1)) exact.length > 0 || unicode.length > 0
.toReversed() ? []
.reduce( : findLineOccurrences(source, oldString)
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`, const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
source, const replacements = matches.length
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
.toReversed()
.reduce(
(content, match) =>
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
source,
)
const preview =
replacements > 0 && (replacements === 1 || input.replaceAll === true)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.canonical))
? yield* Bom.syncFile(fs, target.canonical, bom)
: (yield* Bom.readFile(fs, target.canonical)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
) )
const content = FileMutation.normalizeText(replaced) },
const preview = }),
replacements > 0 && (replacements === 1 || input.replaceAll === true) ),
? fileDiff(target.resource, source, content)
: undefined
yield* permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: preview ? { files: [preview] } : undefined,
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
})
if (replacements === 0) {
return yield* new ToolFailure({
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
})
}
if (replacements > 1 && input.replaceAll !== true) {
return yield* new ToolFailure({
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
})
}
const result = yield* files.write({ target, content: replaced })
return {
files: [fileDiff(result.resource, source, result.content)],
replacements,
} satisfies Output
}).pipe(
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
),
)
},
}),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+85 -62
View File
@@ -5,8 +5,10 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema" import { RelativePath } from "../../schema"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -40,83 +42,104 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.glob", id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false }, name,
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").', options: { codemode: false },
input: Input, description:
output: Output, 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
execute: (input, context) => input: Input,
Effect.gen(function* () { output: Output,
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path execute: (input, context) =>
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } Effect.gen(function* () {
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" }) const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
const external = target.externalDirectory const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
if (external) const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
yield* permission.assert({ const info = yield* fs
action: name, .stat(target.canonical)
resources: [input.pattern], .pipe(
save: ["*"], Effect.catchReason("PlatformError", "NotFound", () =>
metadata: { Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
root: searchPath ?? ".",
path: searchPath,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* filesystem
.glob({
target,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
Effect.catchTag("FileSystem.SearchPathError", (error) =>
Effect.fail(
new ToolFailure({
message:
error.reason === "not_found"
? `Search path does not exist: ${searchPath ?? "."}`
: `Search path is not a directory: ${searchPath ?? "."}`,
}),
), ),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: target.canonical,
pattern: input.pattern,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(root, entry.path))),
}),
),
),
)
return { entries: entries.slice(0, limit), truncated: entries.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
), ),
) metadata: { count: result.entries.length, truncated: result.truncated },
return { entries: entries.slice(0, limit), truncated: entries.length > limit } })),
}).pipe( Effect.mapError((error) =>
Effect.map((result) => ({ error instanceof ToolFailure
output: result.entries, ? error
content: toModelContent( : new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
result.entries.map((entry) => ({ ...entry, path: resolve(location.directory, entry.path) })),
result.truncated,
), ),
metadata: { count: result.entries.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
), ),
), }),
}), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+99 -67
View File
@@ -5,19 +5,21 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema" import { RelativePath } from "../../schema"
export const name = "grep" export const name = "grep"
export const Input = Schema.Struct({ export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern pattern: FileSystem.GrepInput.fields.pattern.check(
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" })) Schema.isMinLength(1, { message: "Pattern must not be empty" }),
.annotate({ ).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)", description: "Regular expression to search for in file contents (ripgrep syntax)",
}), }),
path: Schema.optionalKey(RelativePath).annotate({ path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.", description: "File or directory to search. Defaults to the current working directory.",
}), }),
@@ -56,82 +58,112 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.grep", id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false }, name,
description: options: { codemode: false },
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.", description:
input: Input, "Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
output: Output, input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { execute: (input, context) =>
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } Effect.gen(function* () {
const target = yield* mutation.resolve({ path: input.path ?? "." }) const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
if (target.externalDirectory) const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory), action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
yield* permission.assert({ const root = path.resolve(location.directory, input.path ?? ".")
action: name, const info = yield* fs
resources: [input.pattern], .stat(root)
save: ["*"], .pipe(
metadata: { Effect.catchReason("PlatformError", "NotFound", () =>
root: ".", Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
path: input.path, ),
include: input.include, )
limit: input.limit, const cwd = info?.type === "Directory" ? root : path.dirname(root)
}, const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
sessionID: context.sessionID, const matches = yield* ripgrep
agent: context.agent, .grep({
source, cwd,
}) pattern: input.pattern,
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT file: info?.type === "File" ? path.basename(root) : undefined,
const matches = yield* filesystem include: input.include,
.grep({ limit: limit + 1,
target, })
pattern: input.pattern, .pipe(
include: input.include, Effect.timeoutOrElse({
limit: limit + 1, duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
}) orElse: () =>
.pipe( Effect.fail(
FileSystem.searchTimeout((message) => new ToolFailure({ message })), new ToolFailure({
Effect.catchTag("FileSystem.SearchPathError", () => message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), }),
),
}),
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)),
),
}),
}),
),
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
), ),
) metadata: { matches: result.matches.length, truncated: result.truncated },
return { matches: matches.slice(0, limit), truncated: matches.length > limit } })),
}).pipe( Effect.mapError((error) =>
Effect.map((result) => ({ error instanceof ToolFailure
output: result.matches, ? error
content: toModelContent( : error instanceof Ripgrep.InvalidPatternError
result.matches.map((match) => ({ ? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
...match,
entry: { ...match.entry, path: resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof FileSystem.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
), ),
), }),
}), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+287 -212
View File
@@ -6,8 +6,11 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError" import { PlatformError } from "effect/PlatformError"
import { FileMutation } from "../../file-mutation" import path from "path"
import { LocationMutation } from "../../location-mutation" import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch" import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt" import DESCRIPTION from "../patch.txt"
@@ -54,232 +57,286 @@ type Prepared =
readonly moveTarget?: Target readonly moveTarget?: Target
}) })
type Target = LocationMutation.Target interface Target {
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = { export const Plugin = {
id: "opencode.tool.patch", id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const fs = yield* FSUtil.Service
const files = yield* FileMutation.Service const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false, permission: "edit" }, name,
description: DESCRIPTION, options: { codemode: false, permission: "edit" },
input: Input, description: DESCRIPTION,
output: Output, input: Input,
execute: (input, context) => { output: Output,
const applied: Array<typeof Applied.Type> = [] execute: (input, context) => {
const fail = (operation: string, error: unknown) => { const applied: Array<typeof Applied.Type> = []
const completed = applied.map((item) => item.resource).join(", ") const fail = (operation: string, error: unknown) => {
return new ToolFailure({ const completed = applied.map((item) => item.resource).join(", ")
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`, return new ToolFailure({
}) message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
} })
return Effect.gen(function* () { }
const source = { return Effect.gen(function* () {
type: "tool" as const, const source = {
messageID: context.messageID, type: "tool" as const,
id: context.id, messageID: context.messageID,
} id: context.id,
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) }
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe( if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })), const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
)
if (hunks.length === 0) {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
const prepared: Prepared[] = []
const targets: Target[] = []
const updates = new Map<string, string>()
const resolveHunkTarget = (value: string) =>
mutation
.resolve({ path: value, kind: "file" })
.pipe(
Effect.mapError( Effect.mapError(
(error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }), (error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
), ),
) )
for (const hunk of hunks) { if (hunks.length === 0) {
yield* Effect.gen(function* () { return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const target = yield* resolveHunkTarget(hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
} }
if (hunk.type === "add") { const prepared: Prepared[] = []
const contents = const targets: Target[] = []
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n` const updates = new Map<string, string>()
prepared.push({ for (const hunk of hunks) {
...hunk, yield* Effect.gen(function* () {
contents, const target = resolveTarget(location, hunk.path)
target, targets.push(target)
before: "", if (target.externalDirectory) {
after: FileMutation.normalizeText(contents), yield* permission.assert({
}) action: "external_directory",
return resources: [target.externalDirectory.resource],
} save: [target.externalDirectory.resource],
if (hunk.type === "delete") { metadata: {
const content = yield* files.read(target).pipe( filepath: target.canonical,
Effect.mapError( parentDir: target.externalDirectory.directory,
(error) => },
new ToolFailure({ sessionID: context.sessionID,
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`, agent: context.agent,
}), source,
})
}
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
}),
),
)
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
const original =
previous ??
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
}),
),
)
return Bom.join(content.text, content.bom)
}))
const before = Bom.split(original).text
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
})
if (!moveTarget) updates.set(target.canonical, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }),
), ),
) )
prepared.push({ ...hunk, target, before: content, after: "" })
return
} }
const previous = updates.get(target.canonical)
const original = const patchFiles = prepared.map((change) => patchFile(change))
previous ?? yield* permission.assert({
(yield* files.read(target).pipe( action: "edit",
Effect.mapError( resources: [...new Set(targets.map((target) => target.resource))],
(error) => save: ["*"],
new ToolFailure({ metadata: {
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`, filepath: targets.map((target) => target.resource).join(", "),
}), diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
), files: patchFiles,
)) },
const update = yield* Effect.try({ sessionID: context.sessionID,
try: () => Patch.derive(hunk.path, hunk.chunks, original), agent: context.agent,
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }), source,
}) })
const content = FileMutation.normalizeText(update.content)
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined yield* Effect.forEach(
if (moveTarget) targets.push(moveTarget) prepared,
if (moveTarget?.externalDirectory) { (change) =>
yield* permission.assert({ Effect.gen(function* () {
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory), if (change.type === "add") {
sessionID: context.sessionID, yield* fs
agent: context.agent, .writeWithDirs(
source, change.target.canonical,
}) change.contents.endsWith("\n") || change.contents === ""
} ? change.contents
prepared.push({ : `${change.contents}\n`,
...hunk, )
target, .pipe(
content: Patch.joinBom(update.content, update.bom), Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)),
before: original, )
after: content, applied.push({
moveTarget, type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.type === "delete") {
yield* fs
.remove(change.target.canonical)
.pipe(
Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)),
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* fs
.writeWithDirs(moveTarget.canonical, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs.remove(change.target.canonical).pipe(
Effect.mapError((error) =>
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
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,
resource: change.target.resource,
target: change.target.canonical,
})
}),
{ discard: true },
)
const formatted = new Map<string, string>()
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
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
}) })
if (!moveTarget) updates.set(target.canonical, content) return { applied, files }
}).pipe( }).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ToolFailure error instanceof ToolFailure
? error ? error
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, error }), : new ToolFailure({ message: "Unable to apply patch", error }),
), ),
) )
} },
}),
const patchFiles = prepared.map((change) => patchFile(change)) ),
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
// FileMutation.write formats where the files live and reports
// the final text, so the diff output reflects disk.
const formatted = new Map<string, string>()
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files
.write({ target: change.target, content: change.contents })
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.type === "delete") {
yield* files
.remove(change.target)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
const result = yield* files
.move({ from: change.target, to: moveTarget, content: change.content })
.pipe(
Effect.mapError((error) =>
error instanceof FileMutation.MoveIncompleteError
? fail(
`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`,
error.cause,
)
: fail(`Failed to write ${moveTarget.resource}`, error),
),
)
formatted.set(moveTarget.canonical, result.content)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
const result = yield* files
.write({ target: change.target, content: change.content })
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
formatted.set(change.target.canonical, result.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
}),
{ discard: true },
)
const fileDiffs = 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
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
})
return { applied, files: fileDiffs }
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) =>
error instanceof ToolFailure ? error : new ToolFailure({ message: "Unable to apply patch", error }),
),
)
},
}),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -297,13 +354,8 @@ export const Plugin = {
) )
}), }),
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (error instanceof FileMutation.NotFoundError) return "file does not exist"
if (error instanceof FileMutation.NotAFileError) return "path is a directory"
if (error instanceof LocationMutation.PathError)
return error.reason === "outside_workspace"
? `path is outside the workspace: ${error.path}`
: `ancestor is not a directory: ${error.path}`
if (error instanceof PlatformError) { if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist" if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message return error.reason.description ?? error.reason.message
@@ -313,7 +365,9 @@ function errorMessage(error: unknown) {
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type { function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after)) const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
)
const counts = const counts =
change.type === "delete" change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length } ? { additions: 0, deletions: change.before.split("\n").length }
@@ -360,3 +414,24 @@ function trimDiff(diff: string) {
}) })
.join("\n") .join("\n")
} }
function resolveTarget(location: Location.Interface, value: string): Target {
const canonical =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+90 -86
View File
@@ -25,7 +25,11 @@ const LocationInput = Schema.Struct({
}), }),
}) })
export const Input = LocationInput export const Input = LocationInput
const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage]) const Output = Schema.Union([
ReadToolFileSystem.FileContent,
ReadToolFileSystem.TextPage,
ReadToolFileSystem.ListPage,
])
export const Plugin = { export const Plugin = {
id: "opencode.tool.read", id: "opencode.tool.read",
@@ -39,99 +43,99 @@ export const Plugin = {
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false }, name,
description: options: { codemode: false },
"Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.", description:
input: Input, "Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
output: Output, input: Input,
execute: (input, context) => { output: Output,
return Effect.gen(function* () { execute: (input, context) => {
const source = { return Effect.gen(function* () {
type: "tool" as const, const source = {
messageID: context.messageID, type: "tool" as const,
id: context.id, messageID: context.messageID,
} id: context.id,
const target = yield* mutation.resolve({ path: input.path, kind: "directory" }) }
const external = target.externalDirectory const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
if (external) const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external), action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
const resource = target.resource const type = yield* reader.inspect(absolute).pipe(
const absolute = AbsolutePath.make(target.canonical) Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
// External reads skip discovery, and so do hosted Locations:
// the walk reads the host filesystem.
if (external !== undefined || location.workspaceID !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
) )
if (candidates.length === 0) return const content =
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates }) type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.canonical)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => dirname(file) !== root,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
}).pipe(
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe( }).pipe(
Effect.catch(() => Effect.void), Effect.map((output) => ({
Effect.catchDefect(() => Effect.void), output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
) )
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime)) },
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) }),
return content ),
}).pipe(
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message, error })
}),
)
},
}),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
+14 -7
View File
@@ -4,7 +4,8 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool" import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Array, Deferred, Effect, Schema, Scope } from "effect" import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime" import { PluginRuntime } from "../../plugin/runtime"
@@ -81,6 +82,7 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
@@ -152,12 +154,10 @@ export const Plugin = {
) )
invocation.cwd = target.canonical invocation.cwd = target.canonical
finalTimeout = invocation.timeout finalTimeout = invocation.timeout
const external = Array.dedupeWith( const external = [target, ...directories]
[target, ...directories] .map((item) => item.externalDirectory)
.map((item) => item.externalDirectory) .filter((item) => item !== undefined)
.filter((item) => item !== undefined), .filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
(left, right) => left.resource === right.resource,
)
if (external.length > 0) if (external.length > 0)
yield* permission.assert({ yield* permission.assert({
action: "external_directory", action: "external_directory",
@@ -176,6 +176,13 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
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}`))
}), }),
) )
yield* context.progress({ shellID: info.id }) yield* context.progress({ shellID: info.id })
+58 -54
View File
@@ -9,7 +9,10 @@ export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { fileDiff } from "./file-diff" import { fileDiff } from "./file-diff"
@@ -45,64 +48,65 @@ export const Plugin = {
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false, permission: "edit" }, name,
description: options: { codemode: false, permission: "edit" },
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.", description:
input: Input, "Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
output: Output, input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { execute: (input, context) =>
const source = { Effect.gen(function* () {
type: "tool" as const, const source = {
messageID: context.messageID, type: "tool" as const,
id: context.id, messageID: context.messageID,
} id: context.id,
const target = yield* mutation.resolve({ path: input.path, kind: "file" }) }
const external = target.externalDirectory const target = yield* mutation.resolve({ path: input.path, kind: "file" })
if (external) const external = target.externalDirectory
yield* permission.assert({ if (external)
...LocationMutation.externalDirectoryPermission(external), yield* permission.assert({
sessionID: context.sessionID, ...LocationMutation.externalDirectoryPermission(external),
agent: context.agent, sessionID: context.sessionID,
source, agent: context.agent,
}) source,
const current = yield* files })
.read(target) const current = yield* Bom.readFile(fs, target.canonical).pipe(
.pipe(Effect.catchTag("FileMutation.NotFoundError", () => Effect.succeed(undefined))) Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
const content = FileMutation.normalizeText(input.content) )
const preview = fileDiff( const next = Bom.split(input.content)
target.resource, const preview = fileDiff(
current ?? "", target.resource,
content, current?.text ?? "",
current === undefined ? "added" : "modified", next.text,
) current ? "modified" : "added",
yield* permission.assert({ )
action: "edit", yield* permission.assert({
resources: [target.resource], action: "edit",
save: ["*"], resources: [target.resource],
metadata: { files: [preview] }, save: ["*"],
sessionID: context.sessionID, metadata: { files: [preview] },
agent: context.agent, sessionID: context.sessionID,
source, agent: context.agent,
}) source,
const result = yield* files.write({ target, content: input.content }) })
return { const result = yield* files.writeTextPreservingBom({ target, content: input.content })
operation: result.operation, const bom = (yield* Bom.readFile(fs, target.canonical)).bom
target: result.target, if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
resource: result.resource, return result
existed: result.existed, }).pipe(
} Effect.map((output) => ({ output, content: toModelOutput(output) })),
}).pipe( Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
Effect.map((output) => ({ output, content: toModelOutput(output) })), ),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), }),
), ),
}),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+13 -153
View File
@@ -2,13 +2,11 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema, Scope } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema" import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
import { WorkspaceEnvironment } from "../workspace/environment"
export const MAX_READ_LINES = 2_000 export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024 export const MAX_READ_BYTES = 50 * 1024
@@ -111,24 +109,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
type FileType = WorkspaceEnvironment.FileInfo["type"]
/** The filesystem surface `read` consumes; FSUtil satisfies it structurally. */
export interface ReadSource {
readonly realPath: (path: string) => Effect.Effect<string, FSUtil.Error>
readonly open: (
path: string,
options: { readonly flag: "r" },
) => Effect.Effect<
{
readonly stat: Effect.Effect<{ readonly type: FileType; readonly size: bigint | number }, FSUtil.Error>
readonly readAlloc: (bytes: number) => Effect.Effect<Option.Option<Uint8Array>, FSUtil.Error>
},
FSUtil.Error,
Scope.Scope
>
}
const extensions = new Set([ const extensions = new Set([
".zip", ".zip",
".tar", ".tar",
@@ -189,10 +169,7 @@ const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array)
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) => const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes) bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
export const inspect = Effect.fn("ReadTool.inspect")(function* ( export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
input: string,
) {
const info = yield* fs.stat(input) const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" })) if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
@@ -200,7 +177,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (
}) })
export const read = Effect.fn("ReadTool.read")(function* ( export const read = Effect.fn("ReadTool.read")(function* (
fs: ReadSource, fs: FSUtil.Interface,
input: string, input: string,
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
@@ -233,7 +210,10 @@ export const read = Effect.fn("ReadTool.read")(function* (
type: "file" as const, type: "file" as const,
uri: pathToFileURL(real).href, uri: pathToFileURL(real).href,
name: path.basename(real), name: path.basename(real),
content: Buffer.concat(chunks, total).toString("base64"), content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const, encoding: "base64" as const,
mime, mime,
} }
@@ -354,6 +334,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) { export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input) const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real) const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach( const entries = yield* Effect.forEach(
items, items,
(item) => (item) =>
@@ -371,16 +353,9 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
}), }),
{ concurrency: 16 }, { concurrency: 16 },
) )
return sortAndPage( const visible = entries
entries.filter((item): item is FileSystem.Entry => item !== undefined), .filter((item): item is FileSystem.Entry => item !== undefined)
page, .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
)
})
const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = entries.sort(FileSystem.compareEntries)
const selected = visible.slice(offset - 1, offset - 1 + limit) const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length const truncated = offset - 1 + selected.length < visible.length
return new ListPage({ return new ListPage({
@@ -389,7 +364,7 @@ const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
truncated, truncated,
...(truncated ? { next: offset + selected.length } : {}), ...(truncated ? { next: offset + selected.length } : {}),
}) })
} })
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@@ -404,118 +379,3 @@ const layer = Layer.effect(
) )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
// Reads through WorkspaceEnvironment.Files so hosted sessions never touch the
// host filesystem. The environment has no ranged read, so file reads transfer
// the whole file once and page in memory.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
// Absence surfaces as a platform NotFound so the read tool's missing-file
// handling stays backend-agnostic; other failures become filesystem errors.
const mapError =
(method: string) =>
(error: WorkspaceEnvironment.Error | WorkspaceEnvironment.NotFoundError): FSUtil.Error =>
error._tag === "WorkspaceEnvironment.NotFoundError"
? systemError({ _tag: "NotFound", module: "FileSystem", method, pathOrDescriptor: error.path, cause: error })
: WorkspaceEnvironment.toFileSystemError(method)(error)
const stats = { stat: (target: string) => env.files.stat(target).pipe(Effect.mapError(mapError("stat"))) }
const source: ReadSource = {
// Paths arrive canonical from LocationMutation.resolve, so identity
// avoids a provider canonicalization round trip per read.
realPath: Effect.succeed,
// The environment has no ranged read, so each read call transfers the
// whole file once and pages in memory. Happy-path opens are that one
// transfer; only the failure path stats to classify a non-file target,
// which the shared read reports as PathKindError.
// TODO: Bound and page large hosted reads once environments expose file
// size and ranged reads.
open: (target) =>
env.files.read(target).pipe(
Effect.map((bytes) => {
let cursor = 0
return {
stat: Effect.succeed({ type: "File" as const, size: bytes.length }),
readAlloc: (size: number) =>
Effect.sync(() => {
if (cursor >= bytes.length) return Option.none<Uint8Array>()
const chunk = bytes.subarray(cursor, Math.min(cursor + size, bytes.length))
cursor += chunk.length
return Option.some(chunk)
}),
}
}),
Effect.catchTag("WorkspaceEnvironment.Error", (error) =>
stats.stat(target).pipe(
Effect.catch(() => Effect.fail(mapError("read")(error))),
Effect.flatMap((info) =>
info.type === "File"
? Effect.fail(mapError("read")(error))
: Effect.succeed({
stat: Effect.succeed({ type: info.type, size: 0 }),
readAlloc: () => Effect.succeed(Option.none<Uint8Array>()),
}),
),
),
),
Effect.catchTag("WorkspaceEnvironment.NotFoundError", (error) => Effect.fail(mapError("read")(error))),
),
}
// Matches the local list semantics: symlinks resolve and escape-filter
// against the listed directory. Non-symlink types come from the listing
// itself, keeping provider round trips proportional to symlink count.
const resolveSymlink = Effect.fnUntraced(function* (parent: string, name: string) {
const target = yield* env.files
.realPath(path.posix.join(parent, name))
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (target === undefined || !FSUtil.containsPosix(parent, target)) return undefined
const info = yield* env.files.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (info?.type === "Directory") return "directory" as const
if (info?.type === "File") return "file" as const
return undefined
})
const list = Effect.fn("ReadTool.list")(function* (input: string, page: PageInput = {}) {
const items = yield* env.files.list(input).pipe(Effect.mapError(mapError("list")))
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const type =
item.type === "file" || item.type === "directory"
? item.type
: item.type === "symlink"
? yield* resolveSymlink(input, item.name)
: undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? "/" : "")),
type,
})
}),
{ concurrency: 4 },
)
return sortAndPage(
entries.filter((item): item is FileSystem.Entry => item !== undefined),
page,
)
})
return Service.of({
inspect: (target) => inspect(stats, target),
read: (target, resource, page) => read(source, target, resource, page),
list,
})
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer,
deps: [WorkspaceEnvironment.node],
})
-74
View File
@@ -1,80 +1,6 @@
export * as Workspace from "./workspace" export * as Workspace from "./workspace"
import { eq } from "drizzle-orm"
import { Context, Effect, Exit, Layer, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace" 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 const ID = Workspace.ID
export type ID = typeof ID.Type 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* Effect.acquireUseRelease(
driver.create({ workspaceID: id }),
(created) =>
db
.insert(WorkspaceTable)
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
.pipe(Effect.orDie, Effect.as(created)),
(created, exit) => (Exit.isFailure(exit) ? driver.destroy(created.binding).pipe(Effect.ignore) : Effect.void),
)
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: [],
})
-133
View File
@@ -1,133 +0,0 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
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,
}) {}
/** Translate environment failures into the host filesystem error vocabulary. */
export const toFileSystemError =
(method: string) =>
(cause: Error | NotFoundError): FSUtil.Error =>
new FSUtil.FileSystemError({ method, cause })
/**
* Wrap one driver promise, translating the driver's not-found signal into the
* environment error vocabulary so drivers and fakes never construct it ad hoc.
*/
export const tryOperation = <A>(input: {
readonly operation: string
readonly path: string
readonly run: () => Promise<A>
readonly isNotFound: (cause: unknown) => boolean
}): Effect.Effect<A, Error | NotFoundError> =>
Effect.tryPromise({
try: input.run,
catch: (cause) =>
input.isNotFound(cause)
? new NotFoundError({ path: input.path })
: new Error({ operation: input.operation, path: input.path, cause }),
})
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. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, 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,
})
+5
View File
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () => it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test") const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect( expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect, Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow") ).toBe("allow")
+2 -30
View File
@@ -46,7 +46,6 @@ describe("FileMutation", () => {
target: target.canonical, target: target.canonical,
resource: "hello.txt", resource: "hello.txt",
existed: true, existed: true,
content: "after",
}) })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after") expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -66,7 +65,6 @@ describe("FileMutation", () => {
target: target.canonical, target: target.canonical,
resource: "src/nested/hello.txt", resource: "src/nested/hello.txt",
existed: false, existed: false,
content: "hello",
}) })
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello") expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -82,8 +80,8 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" }) const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
yield* files.write({ target: preserved, content: "\uFEFFafter" }) yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
yield* files.write({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" }) yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated") expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
@@ -91,31 +89,6 @@ describe("FileMutation", () => {
), ),
) )
it.live("moves a file and transfers its BOM to the destination", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fromPath = path.join(directory, "from.txt")
yield* Effect.promise(() => fs.writeFile(fromPath, "\uFEFFbefore"))
const mutation = yield* LocationMutation.Service
const from = yield* mutation.resolve({ path: "from.txt" })
const to = yield* mutation.resolve({ path: "to.txt" })
const result = yield* (yield* FileMutation.Service).move({ from, to, content: "after" })
expect(result.content).toBe("after")
expect(yield* Effect.promise(() => fs.readFile(to.canonical, "utf8"))).toBe("\uFEFFafter")
expect(
yield* Effect.promise(() =>
fs.access(fromPath).then(
() => true,
() => false,
),
),
).toBe(false)
}).pipe(provide(directory)),
),
)
it.live("writes an explicitly resolved external target", () => it.live("writes an explicitly resolved external target", () =>
withTmp((directory) => withTmp((directory) =>
withTmp((outside) => withTmp((outside) =>
@@ -129,7 +102,6 @@ describe("FileMutation", () => {
target: target.canonical, target: target.canonical,
resource: target.resource, resource: target.resource,
existed: false, existed: false,
content: "external",
}) })
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external") expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)), }).pipe(provide(directory)),
-161
View File
@@ -1,161 +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>) =>
WorkspaceEnvironment.tryOperation({
operation,
path,
run,
isNotFound: (cause) => (cause as NodeJS.ErrnoException).code === "ENOENT",
})
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 () => {
await mkdir(nodePath.dirname(path), { recursive: true })
await writeFile(path, content)
},
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(() => {
store.set(path, Uint8Array.from(content))
}),
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
},
}
}
@@ -163,35 +163,6 @@ describe("LocationMutation", () => {
), ),
) )
it.live("authorizes an external symlink by the named path", () =>
withTmp((directory) =>
withTmp((outside) =>
withTmp((referent) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const targetPath = path.join(referent, "target.txt")
const linkPath = path.join(outside, "link.txt")
yield* Effect.promise(async () => {
await fs.writeFile(targetPath, "content")
await fs.symlink(targetPath, linkPath)
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: linkPath })
expect(target).toMatchObject({
absolute: linkPath,
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
resource: linkPath.replaceAll("\\", "/"),
externalDirectory: {
directory: outside,
resource: path.join(outside, "*").replaceAll("\\", "/"),
},
})
}).pipe(provide(directory)),
),
),
),
)
it.live("anchors prospective external descendants at their stable existing directory", () => it.live("anchors prospective external descendants at their stable existing directory", () =>
withTmp((directory) => withTmp((directory) =>
withTmp((outside) => withTmp((outside) =>
+8 -4
View File
@@ -3,6 +3,7 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node)) 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* () { Effect.gen(function* () {
const tickets = yield* PtyTicket.Service const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending() 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, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true) false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}), }),
) )
}) })
+4 -2
View File
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -126,6 +127,7 @@ describe("Session.create", () => {
it.effect("stores supplied immutable create attributes", () => it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({ const model = Model.Ref.make({
id: Model.ID.make("sonnet"), id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic, providerID: Provider.ID.anthropic,
@@ -134,11 +136,11 @@ describe("Session.create", () => {
expect( expect(
yield* session.create({ yield* session.create({
location: Location.Ref.make({ directory: location.directory }), location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
model, model,
}), }),
).toMatchObject({ location: { directory: location.directory }, agent: "build", model }) ).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
}), }),
) )
+3 -25
View File
@@ -13,8 +13,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch" import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -24,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({ const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin", name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node], deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_patch_tool_test") const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -606,24 +604,6 @@ describe("PatchTool", () => {
), ),
) )
it.live("preserves a BOM when moving a file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "source.txt")
const moved = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(source, "\uFEFFbefore\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: source.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("\uFEFFafter\n")
}),
),
)
it.live("rejects an update with missing context", () => it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) => withTempTool((directory, registry) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -848,9 +828,7 @@ describe("PatchTool", () => {
), ),
) )
// Paths outside the active Location require external approval — the same it.live("treats a sibling path inside the project worktree as internal", () =>
// boundary the edit and write tools derive from LocationMutation.
it.live("requires external approval for sibling paths outside the location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
@@ -869,7 +847,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
), ),
).toMatchObject({ status: "completed" }) ).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}), }),
tmp.path, tmp.path,
-1
View File
@@ -155,7 +155,6 @@ const mutation = Layer.succeed(
const externalResource = path.join(directory, "*").replaceAll("\\", "/") const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({ return Effect.succeed({
canonical, canonical,
absolute: canonical,
resource, resource,
externalDirectory: external externalDirectory: external
? { ? {
+20 -30
View File
@@ -6,9 +6,11 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/plugin/glob" import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
@@ -22,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({ const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin", name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node], deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
}) })
const grepToolNode = makeLocationNode({ const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin", name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node], deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_search_tool_test") const sessionID = Session.ID.make("ses_search_tool_test")
@@ -172,37 +181,14 @@ describe("search tools", () => {
), ),
) )
it.live("reports the lexical path when grepping a symlinked file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "actual"))
await fs.writeFile(path.join(tmp.path, "actual", "target.txt"), "needle\n")
await fs.symlink(path.join(tmp.path, "actual", "target.txt"), path.join(tmp.path, "link.txt"))
}).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const result = yield* executeTool(registry, call("grep", { path: "link.txt", pattern: "needle" }))
expect(result).toMatchObject({
status: "completed",
output: [{ entry: { path: "link.txt" }, line: 1, text: "needle\n" }],
})
}),
),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("reports no grep matches", () => it.live("reports no grep matches", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => (tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe( Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))), Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) => Effect.tap((result) =>
Effect.sync(() => { Effect.sync(() => {
expect(result).toMatchObject({ expect(result).toMatchObject({
@@ -311,7 +297,9 @@ describe("search tools", () => {
(tmp) => (tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe( Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen( Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))), withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
), ),
Effect.tap((result) => Effect.tap((result) =>
Effect.sync(() => { Effect.sync(() => {
@@ -343,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => { Effect.sync(() => {
expect(result.status).toBe("completed") expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"]) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")]) expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}), }),
), ),
) )
-150
View File
@@ -1,150 +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 && ln -s /etc escaped && 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")
const root = yield* mutation.resolve({ path: "." })
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
const limited = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 1 })
expect(limited).toHaveLength(1)
const matches = yield* filesystem.grep({
target: yield* mutation.resolve({ path: "hello.txt" }),
pattern: "workspace",
limit: 10,
})
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
const invalid = yield* filesystem.grep({ target: root, pattern: "[", limit: 10 }).pipe(Effect.flip)
expect(invalid).toMatchObject({ _tag: "FileSystem.InvalidPatternError", pattern: "[" })
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}).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,79 +0,0 @@
import { describe, expect, test } from "bun:test"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { hostedReplacements, locationServices } from "@opencode-ai/core/location-services"
import { Workspace } from "@opencode-ai/core/workspace"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
// Location-scoped services receive Location-relative and Location-absolute
// paths from tools. In a hosted graph those are provider paths (for example
// /workspace/src), so any Location-scoped service that still depends on a
// host primitive would silently run provider paths against the host machine.
// This is the guardrail for the class of bug where the read tool's filesystem
// stayed host-backed after the hosted rollout.
//
// Every entry here is a documented decision, not an exemption by default.
// Adding a new Location-scoped service that touches FSUtil or AppProcess
// fails this test until the service either gets a hosted replacement in
// hostedReplacements or is justified below.
const allowed: Record<string, string> = {
// Host-side by design: hosted graphs read only global (host) configuration.
"@opencode/Config": "global host config sources only (Config.configured({ project: false }))",
"@opencode/InstructionDiscovery": "global host instructions only (configured({ project: false }))",
"@opencode/Skill": "skills load from host config directories",
"@opencode/PluginSupervisor": "plugins are host processes loaded from host paths",
"@opencode/Integration": "integrations run host-side CLIs",
"@opencode/ProjectCopy": "project copy refresh operates on host-owned global project data",
// Known gaps: these never advertise hosted capability, but their services
// still exist in hosted graphs and would touch host paths if invoked with
// provider paths. Each needs an environment-backed design or an explicit
// hosted no-op before workspaces ship publicly.
"@opencode/Command": "TODO: `!command` template substitution would run on the host",
"@opencode/LocationWatcher": "TODO: watches the provider directory path on the host filesystem",
"@opencode/Snapshot": "TODO: hosted snapshots need environment-backed git",
"@opencode/Vcs": "TODO: hosted VCS info is stated by Project.hostedGlobal, service methods remain host git",
"@opencode/v2/Formatter": "TODO: formatters are host binaries; hosted FileMutation deliberately omits format",
"session-instructions": "TODO: per-session instruction file loads read the host filesystem",
}
describe("hosted location graph", () => {
test("location-scoped services do not use host primitives without justification", () => {
const workspaceID = Workspace.ID.make("wrk_graphguard000000000000000")
const ref = Location.Ref.make({ directory: AbsolutePath.make("/workspace"), workspaceID })
// Replacement targets may be bare layers in general; every hosted
// replacement is a node today, and bare-layer targets have no walkable
// dependencies anyway.
const replaced = new Map<LayerNode.AnyNode, LayerNode.AnyNode>(
hostedReplacements(ref, workspaceID).flatMap(([from, to]) => (LayerNode.isNode(to) ? [[from, to] as const] : [])),
)
const resolve = (node: LayerNode.AnyNode) => replaced.get(node) ?? node
const hostPrimitives = new Set<LayerNode.AnyNode>([FSUtil.node, AppProcess.node])
const offenders = new Map<string, string[]>()
LayerNode.walk<void>(
locationServices,
(node, context) => {
for (const dependency of node.dependencies) {
if (hostPrimitives.has(resolve(dependency)) && node.tag === Node.tags.values.location) {
offenders.set(node.name, [...(offenders.get(node.name) ?? []), resolve(dependency).name])
}
context.visit(dependency)
}
},
{ resolve },
)
const unjustified = [...offenders.entries()]
.filter(([name]) => !(name in allowed))
.map(([name, primitives]) => `${name} -> ${primitives.join(", ")}`)
.sort()
expect(unjustified).toEqual([])
// Stale allowlist entries must be removed so the inventory stays honest.
const stale = Object.keys(allowed).filter((name) => !offenders.has(name))
expect(stale).toEqual([])
})
})
@@ -1,95 +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 { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { AbsolutePath } from "@opencode-ai/core/schema"
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, ReadToolFileSystem.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.write({ target: created, content: "- shipped\n" })
expect(second.existed).toBe(true)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
// The reader must consume the environment, never the host filesystem:
// these paths exist only in the memory store.
it.effect("reads files and directories through the environment", () =>
Effect.gen(function* () {
const reader = yield* ReadToolFileSystem.Service
expect(yield* reader.inspect(AbsolutePath.make("/workspace/README.md"))).toBe("file")
expect(yield* reader.inspect(AbsolutePath.make("/workspace/src"))).toBe("directory")
const content = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md")
expect(content).toMatchObject({ type: "file", content: "# hello\n", encoding: "utf8" })
const paged = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md", { limit: 1 })
expect(paged).toMatchObject({ type: "text-page", content: "# hello", offset: 1, truncated: false })
const listed = yield* reader.list(AbsolutePath.make("/workspace/src"))
expect(listed.entries.map((entry) => String(entry.path))).toEqual(["index.ts"])
const missing = yield* reader.inspect(AbsolutePath.make("/workspace/nope.txt")).pipe(Effect.flip)
expect(missing).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } })
}),
)
})
-175
View File
@@ -1,175 +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 { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
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"),
}),
)
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, LocationMutation.node, FileMutation.node, Permission.node],
})
return Effect.gen(function* () {
assertions.length = 0
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[Location.node, hostedLocationLayer()],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[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("reports hosted write failures through the tool error channel", () => {
const base = memoryEnvironment({})
const memory: MemoryEnvironment = {
...base,
environment: WorkspaceEnvironment.make({
...base.environment,
files: {
...base.environment.files,
write: (path, content) =>
path.endsWith("fail.txt")
? Effect.fail(new WorkspaceEnvironment.Error({ operation: "write", path }))
: base.environment.files.write(path, content),
},
}),
}
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: fail.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("Failed to write fail.txt")
}),
)
})
it.effect("rejects targets outside the workspace", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: /outside/new.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("path is outside the workspace")
expect(assertions).toEqual([])
expect(memory.contents("/outside/new.txt")).toBeUndefined()
}),
)
})
})
@@ -1,187 +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.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
TestLLM.tool("call-read", "read", { path: "from-patch.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 includes search tools now that they execute through
// the Workspace process seam, plus patch as the gpt-style editor.
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).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
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(2)).toMatchObject({
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
})
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
}),
)
})
+33
View File
@@ -23,11 +23,44 @@ export type ProviderContext = {
options: Record<string, any> 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 = { export type PluginInput = {
client: ReturnType<typeof createOpencodeClient> client: ReturnType<typeof createOpencodeClient>
project: Project project: Project
directory: string directory: string
worktree: string worktree: string
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void
}
serverUrl: URL serverUrl: URL
$: BunShell $: BunShell
} }
+1
View File
@@ -6,6 +6,7 @@ export const LocationQuery = Schema.Struct({
location: Schema.optional( location: Schema.optional(
Schema.Struct({ Schema.Struct({
directory: Schema.optional(Schema.String), directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}), }),
), ),
}).annotate({ identifier: "LocationQuery" }) }).annotate({ identifier: "LocationQuery" })
+1 -1
View File
@@ -130,7 +130,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
"x-websocket": true, "x-websocket": true,
parameters: [ parameters: [
...(operation.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", in: "query",
name, name,
schema: { type: "string" }, schema: { type: "string" },
+5 -4
View File
@@ -7,6 +7,7 @@ import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project" import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event" 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 { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { import {
@@ -42,6 +43,7 @@ const ParentIDFilter = Schema.Union([
}) })
const SessionsQueryFields = { const SessionsQueryFields = {
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({ limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}), }),
@@ -149,17 +151,16 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
payload: Schema.Struct({ payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional), id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional), agent: Agent.ID,
model: Model.Ref.pipe(Schema.optional), model: Model.Ref,
location: Location.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional),
}), }),
success: Schema.Struct({ data: Session.Info }), success: Schema.Struct({ data: Session.Info }),
error: InvalidRequestError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.session.create", identifier: "v2.session.create",
summary: "Create session", summary: "Create session",
description: "Create a session at the requested location.", description: "Create a session with an explicit agent and model at the requested location.",
}), }),
), ),
) )
@@ -6,6 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => { describe("SessionsCursor", () => {
test("round trips without Node globals", async () => { test("round trips without Node globals", async () => {
const input = { const input = {
workspace: undefined,
search: "protocol", search: "protocol",
order: "desc" as const, order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const }, anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
+2
View File
@@ -31,6 +31,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionStatusEvent } from "./session-status-event.js" import { SessionStatusEvent } from "./session-status-event.js"
import { TuiEvent } from "./tui-event.js" import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js" import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js" import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js" import { WebSearch } from "./websearch.js"
@@ -88,6 +89,7 @@ export const Definitions = Event.inventory(
...SessionStatusEvent.Definitions, ...SessionStatusEvent.Definitions,
...SessionCompactionEvent.Definitions, ...SessionCompactionEvent.Definitions,
...VcsEvent.Definitions, ...VcsEvent.Definitions,
...WorkspaceEvent.Definitions,
...WorktreeEvent.Definitions, ...WorktreeEvent.Definitions,
...ServerEvent.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" export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js" import { WorkspaceID } from "./workspace-id.js"
export const ID = WorkspaceID export const ID = WorkspaceID
export type ID = WorkspaceID export type ID = WorkspaceID
export const Event = WorkspaceEvent
+4 -1
View File
@@ -9,6 +9,7 @@ import {
Project, Project,
Reference, Reference,
Session, Session,
Workspace,
} from "../src/index.js" } from "../src/index.js"
import { EventManifest } from "../src/event-manifest.js" import { EventManifest } from "../src/event-manifest.js"
import { FileSystemV1 } from "../src/filesystem-v1.js" import { FileSystemV1 } from "../src/filesystem-v1.js"
@@ -18,7 +19,7 @@ import { Plugin } from "../src/plugin.js"
import { SessionEvent } from "../src/session-event.js" import { SessionEvent } from "../src/session-event.js"
import { SessionID } from "../src/session-id.js" import { SessionID } from "../src/session-id.js"
import { SessionMessage } from "../src/session-message.js" import { SessionMessage } from "../src/session-message.js"
import { WorkspaceEvent } from "../src/workspace-event.js"
describe("public event manifest", () => { describe("public event manifest", () => {
test("owns the complete public event surface", () => { test("owns the complete public event surface", () => {
@@ -47,6 +48,8 @@ describe("public event manifest", () => {
test("uses canonical definitions for current public events", () => { test("uses canonical definitions for current public events", () => {
expect(Session.Event).toBe(SessionEvent) expect(Session.Event).toBe(SessionEvent)
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
expect(Workspace.Event).toBe(WorkspaceEvent)
expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
+1 -3
View File
@@ -20,11 +20,9 @@
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:"
"modal": "0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:" "@typescript/native-preview": "catalog:"
+1
View File
@@ -10,6 +10,7 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
const location = yield* Location.Service const location = yield* Location.Service
return new Location.Info({ return new Location.Info({
directory: location.directory, directory: location.directory,
workspaceID: location.workspaceID,
project: location.project, project: location.project,
}) })
}), }),
+1 -1
View File
@@ -19,7 +19,7 @@ import { PtyEnvironment } from "../pty-environment"
const ticketScope = Effect.gen(function* () { const ticketScope = Effect.gen(function* () {
const location = yield* Location.Service const location = yield* Location.Service
return { directory: location.directory as string } return { directory: location.directory as string, workspaceID: location.workspaceID }
}) })
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
+3 -12
View File
@@ -39,6 +39,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
: ctx.query : ctx.query
const page = yield* session.list({ const page = yield* session.list({
...query, ...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit, limit: ctx.query.limit ?? DefaultSessionsLimit,
}) })
const sessions = page.data const sessions = page.data
@@ -83,15 +84,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
model: ctx.payload.model, model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
}) })
.pipe( .pipe(Effect.orDie),
Effect.catchTags({
"Workspace.NotFoundError": (error) =>
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
"Session.WorkspaceDirectoryError": (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
}),
Effect.orDie,
),
} }
}), }),
) )
@@ -272,6 +265,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.move({ .move({
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
directory: ctx.payload.directory, directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
}) })
.pipe( .pipe(
Effect.catchTag("Session.NotFoundError", (error) => Effect.catchTag("Session.NotFoundError", (error) =>
@@ -462,9 +456,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}), }),
), ),
), ),
Effect.catchTag("Shell.InvalidCwdError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
) )
return HttpApiSchema.NoContent.make() return HttpApiSchema.NoContent.make()
}), }),
+2 -6
View File
@@ -2,7 +2,7 @@ import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InvalidRequestError, ShellNotFoundError } from "@opencode-ai/protocol/errors" import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
import { Api } from "../api" import { Api } from "../api"
import { response } from "../location" import { response } from "../location"
@@ -21,11 +21,7 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
const shell = yield* Shell.Service const shell = yield* Shell.Service
const location = yield* Location.Service const location = yield* Location.Service
return yield* response( return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }))
shell
.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory })
.pipe(Effect.mapError((error) => new InvalidRequestError({ message: error.message }))),
)
}), }),
) )
.handle( .handle(
+3
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services" import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http" import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -27,11 +28,13 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref { export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
const directory = const directory =
query.get("location[directory]") || query.get("location[directory]") ||
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd()) (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
return Location.Ref.make({ return Location.Ref.make({
directory: AbsolutePath.make(directory), directory: AbsolutePath.make(directory),
workspaceID: workspaceID ? Workspace.ID.make(workspaceID) : undefined,
}) })
} }
@@ -4,6 +4,7 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http" import { HttpRouter } from "effect/unstable/http"
@@ -39,7 +40,7 @@ export const sessionLocationLayer = Layer.effect(
), ),
) )
const row = yield* db const row = yield* db
.select({ directory: SessionTable.directory, workspace_id: SessionTable.workspace_id }) .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable) .from(SessionTable)
.where(eq(SessionTable.id, sessionID)) .where(eq(SessionTable.id, sessionID))
.get() .get()
@@ -53,11 +54,9 @@ export const sessionLocationLayer = Layer.effect(
return yield* effect.pipe( return yield* effect.pipe(
Effect.provide( Effect.provide(
locations.get( locations.get(
// Hosted-ness is a server-side fact from the session row; clients
// never assert workspace identity per-request.
Location.Ref.make({ Location.Ref.make({
directory: AbsolutePath.make(row.directory), directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ?? undefined, workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}), }),
), ),
), ),
-3
View File
@@ -41,8 +41,6 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location" import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location" import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location" import { sessionLocationLayer } from "./middleware/session-location"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ServerWorkspaceDrivers } from "./workspace/drivers"
import { ServerInfo } from "./server-info" import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options" import type { ServerOptions } from "./options"
@@ -88,7 +86,6 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)], [Database.node, Database.configured(options.database)],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[Bus.node, Bus.configured({ persist: options.events?.persist })], [Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)], [App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)], [ModelsDev.node, ModelsDev.configured(options.models)],
-18
View File
@@ -1,18 +0,0 @@
export * as ServerWorkspaceDrivers from "./drivers"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ModalDriver } from "./modal"
/** Replaces core's empty default registry in Server composition. */
export const node = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: Layer.effect(
WorkspaceDriver.RegistryService,
Effect.map(ModalDriver.make, (modal) =>
WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ modal })),
),
),
deps: [],
})
-201
View File
@@ -1,201 +0,0 @@
export * as ModalDriver from "./modal"
import { Effect, Schema, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command } from "effect/unstable/process/ChildProcess"
import { ExitCode, ProcessId, make as makeSpawner, makeHandle } from "effect/unstable/process/ChildProcessSpawner"
import { ModalClient, type Sandbox, SandboxFilesystemNotFoundError, NotFoundError } from "modal"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
const PROVIDER = "modal"
const APP = "opencode-workspaces"
const ROOT = "/workspace"
/** Maximum sandbox lifetime; stop/resume/TTL lifecycle is a deferred design. */
const SANDBOX_TIMEOUT_MS = 60 * 60 * 1000
/**
* Backstop for commands: Modal exposes no per-process termination, so kill is
* best-effort and this deadline is what actually reaps orphaned commands.
*/
const EXEC_TIMEOUT_MS = 30 * 60 * 1000
const Binding = Schema.Struct({ sandboxId: Schema.String })
const fail = (message: string) => (cause: unknown) => new WorkspaceDriver.Error({ provider: PROVIDER, message, cause })
// git, bash, and rg are provisioned in the image, never discovered.
const IMAGE_COMMANDS = ["RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep ca-certificates"]
export const make = Effect.sync(() => {
// Credentials come from MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml.
const client = new ModalClient()
const decode = (binding: WorkspaceDriver.Binding) =>
Schema.decodeUnknownEffect(Binding)(binding).pipe(Effect.mapError(fail("invalid modal binding")))
return WorkspaceDriver.make({
create: ({ workspaceID }) =>
Effect.tryPromise({
try: async () => {
const app = await client.apps.fromName(APP, { createIfMissing: true })
const image = client.images.fromRegistry("ubuntu:24.04").dockerfileCommands(IMAGE_COMMANDS)
const sandbox = await client.sandboxes.create(app, image, {
name: workspaceID,
timeoutMs: SANDBOX_TIMEOUT_MS,
})
await sandbox.filesystem.makeDirectory(ROOT)
return { binding: { sandboxId: sandbox.sandboxId }, root: ROOT }
},
catch: fail("create"),
}),
connect: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
const sandbox = yield* Effect.tryPromise({
try: () => client.sandboxes.fromId(decoded.sandboxId),
catch: fail("connect"),
})
const exit = yield* Effect.tryPromise({ try: () => sandbox.poll(), catch: fail("connect") })
if (exit !== null)
return yield* new WorkspaceDriver.Error({
provider: PROVIDER,
message: `sandbox ${decoded.sandboxId} has terminated (exit ${exit})`,
})
return environment(sandbox)
}),
destroy: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
yield* Effect.tryPromise({
try: async () => {
const sandbox = await client.sandboxes.fromId(decoded.sandboxId)
await sandbox.terminate({ wait: true })
},
catch: fail("destroy"),
}).pipe(
// Already gone is destroyed.
Effect.catchIf(
(error) => error.cause instanceof NotFoundError,
() => Effect.void,
),
)
}),
})
})
const environment = (sandbox: Sandbox): WorkspaceEnvironment.Interface => ({
directory: ROOT,
files: files(sandbox),
process: makeSpawner(spawn(sandbox)),
shell: WorkspaceEnvironment.linuxShell,
})
const FILE_TYPE = { file: "File", directory: "Directory", symlink: "SymbolicLink" } as const
const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
WorkspaceEnvironment.tryOperation({
operation,
path,
run,
isNotFound: (cause) => cause instanceof SandboxFilesystemNotFoundError,
})
return {
stat: (path) =>
wrap("stat", path, async () => {
const info = await sandbox.filesystem.stat(path)
return { type: FILE_TYPE[info.type] }
}),
realPath: (path) =>
// No canonicalization API; realpath(1) from coreutils. A non-zero exit
// is reported as NotFound, which matches the dominant failure mode.
wrap("realPath", path, async () => {
// Both streams piped: the SDK's "ignore" path needs ReadableStream.from,
// which Bun does not implement.
// -e: every component must exist, matching node fs.realpath semantics.
const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" })
const [output, code] = await Promise.all([process.stdout.readText(), process.wait()])
if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`)
return output.trim()
}),
read: (path) => wrap("read", path, () => sandbox.filesystem.readBytes(path)),
list: (path) =>
wrap("list", path, async () => {
const entries = await sandbox.filesystem.listFiles(path)
return entries.map((entry) => ({
name: entry.name,
type:
entry.type === "file"
? ("file" as const)
: entry.type === "directory"
? ("directory" as const)
: ("symlink" as const),
}))
}),
// Parent directories are created by the SDK, matching the Files contract.
write: (path, content) =>
Effect.tryPromise({
try: () => sandbox.filesystem.writeBytes(content, path),
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => sandbox.filesystem.remove(path)),
}
}
const spawnError = (method: string, options?: { description?: string; cause?: unknown }) =>
systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options })
const spawn = (sandbox: Sandbox) => {
let pids = 0
return Effect.fnUntraced(function* (command: Command) {
if (command._tag === "PipedCommand")
return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" }))
const process = yield* Effect.tryPromise({
try: () =>
sandbox.exec([command.command, ...command.args], {
mode: "binary",
stdout: "pipe",
stderr: "pipe",
workdir: command.options.cwd,
env: compact(command.options.env),
timeoutMs: EXEC_TIMEOUT_MS,
}),
catch: (cause) => spawnError("spawn", { cause }),
})
let exited = false
let waited: Promise<number> | undefined
const wait = () =>
(waited ??= process.wait().then((code) => {
exited = true
return code
}))
const onError = (cause: unknown) => spawnError("process", { cause })
return makeHandle({
pid: ProcessId(++pids),
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
isRunning: Effect.sync(() => !exited),
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
stdin: Sink.forEach((chunk: Uint8Array) =>
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
),
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
all: Stream.merge(
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
),
getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })),
getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })),
unref: Effect.succeed(Effect.void),
})
})
}
const compact = (env: Record<string, string | undefined> | undefined) => {
if (!env) return undefined
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
}
@@ -1,100 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } 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 { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db")
const layer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
],
)
// The complete hosted stack against real Modal: workspace creation, the full
// Location graph, file mutation, real bash in the sandbox, eviction, reconnect.
describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => {
test(
"runs the location graph against a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
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)
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 opencode\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "cat hello.txt && printf 'bash-made' > bash.txt && ln -s /etc escaped",
timeout: 60_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 opencode")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made")
const root = yield* mutation.resolve({ path: "." })
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
const matches = yield* filesystem.grep({
target: yield* mutation.resolve({ path: "hello.txt" }),
pattern: "opencode",
limit: 10,
})
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}).pipe(Effect.provide(locations.get(ref)))
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 opencode\n")
}).pipe(Effect.provide(locations.get(ref)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -1,153 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } 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, RelativePath } from "@opencode-ai/core/schema"
import { FileSystem } from "@opencode-ai/core/filesystem"
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 { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-session-")), "session.db")
// 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: [],
}),
),
})
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[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") })
// A scripted model through the real runner against a real Modal sandbox: the
// session-level counterpart of workspace-modal-graph.test.ts.
describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
test(
"runs a scripted model through the real runner on a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
const sessions = yield* Session.Service
const location = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
const session = yield* sessions.create({
title: "hosted modal session",
location,
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.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
// Read must go through the sandbox filesystem: the host has no
// /workspace/from-patch.txt, so a host-backed reader would fail.
TestLLM.tool("call-read", "read", { path: "from-patch.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)
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).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
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(2)).toMatchObject({
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
})
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
// Both tools executed inside the sandbox: read the files back through
// the hosted filesystem rather than any host path.
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const written = yield* filesystem.read({ path: RelativePath.make("from-model.txt") })
expect(new TextDecoder().decode(written.content)).toBe("from-model")
const patched = yield* filesystem.read({ path: RelativePath.make("from-patch.txt") })
expect(new TextDecoder().decode(patched.content)).toBe("patched\n")
}).pipe(Effect.provide(locations.get(location)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -1,80 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync } from "fs"
import { homedir } from "os"
import path from "path"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Workspace } from "@opencode-ai/core/workspace"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
// Live contract test against real Modal. First run may build the image
// (apt-get install), so the budget is generous.
describe.skipIf(!hasCredentials)("modal driver (live)", () => {
test(
"create, connect, files, exec, reconnect, destroy",
async () => {
await Effect.gen(function* () {
const driver = yield* ModalDriver.make
const workspaceID = Workspace.ID.create()
const created = yield* driver.create({ workspaceID })
expect(created.root).toBe("/workspace")
const cleanup = () => Effect.ignore(driver.destroy(created.binding))
yield* Effect.addFinalizer(cleanup)
const env = yield* driver.connect(created.binding)
expect(env.directory).toBe("/workspace")
// files: writes create parents and overwrite, read round-trips, stat sees a file
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello modal\n"))
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello again\n"))
yield* env.files.write("/workspace/nested/deep/hello.txt", new TextEncoder().encode("nested\n"))
expect(
yield* env.files
.read("/workspace/nested/deep/hello.txt")
.pipe(Effect.map((b) => new TextDecoder().decode(b))),
).toBe("nested\n")
const bytes = yield* env.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(bytes)).toBe("hello again\n")
expect((yield* env.files.stat("/workspace/hello.txt")).type).toBe("File")
expect(yield* env.files.realPath("/workspace")).toBe("/workspace")
const listed = yield* env.files.list("/workspace")
expect(listed.map((entry) => entry.name)).toContain("hello.txt")
// missing files are typed NotFound
const missing = yield* env.files.read("/workspace/nope.txt").pipe(Effect.flip)
expect(missing._tag).toBe("WorkspaceEnvironment.NotFoundError")
// exec: bash sees the file, git and rg are provisioned
const handle = yield* env.process.spawn(
ChildProcess.make(
env.shell.executable,
[...env.shell.args("cat hello.txt && git --version && rg --version | head -1")],
{
cwd: env.directory,
stdin: "ignore",
},
),
)
const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode])
expect(Number(code)).toBe(0)
expect(output).toContain("hello again")
expect(output).toContain("git version")
expect(output).toContain("ripgrep")
// reconnect by binding: durable contents survive a fresh connection
const again = yield* driver.connect(created.binding)
const persisted = yield* again.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(persisted)).toBe("hello again\n")
// destroy, then connect must fail typed
yield* driver.destroy(created.binding)
const dead = yield* driver.connect(created.binding).pipe(Effect.flip)
expect(dead._tag).toBe("WorkspaceDriver.Error")
}).pipe(Effect.scoped, Effect.runPromise)
},
{ timeout: 600_000 },
)
})
+2 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32" const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width) const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () => const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
@@ -584,6 +584,7 @@ function App(props: { pair?: DialogPairCredentials }) {
order: "desc", order: "desc",
parentID: null, parentID: null,
directory: location.directory, directory: location.directory,
workspace: location.workspaceID,
}) })
.then((response) => { .then((response) => {
const match = response.data[0]?.id const match = response.data[0]?.id
+4 -5
View File
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
labels: ["current directory", "global"], labels: ["current directory", "global"],
}, },
{ {
title: "Vertical", title: "Layout",
category: "Tabs", category: "Tabs",
path: ["tabs", "vertical"], path: ["tabs", "layout"],
default: false, default: "horizontal",
values: [false, true], values: ["horizontal", "vertical"],
labels: ["off", "on"],
keywords: ["sidebar", "orientation", "left"], keywords: ["sidebar", "orientation", "left"],
}, },
{ {
@@ -713,7 +713,7 @@ async function disconnected(
function location(data: ReturnType<typeof useData>) { function location(data: ReturnType<typeof useData>) {
const current = data.location.default() const current = data.location.default()
return { directory: current.directory } return { directory: current.directory, workspace: current.workspaceID }
} }
function message(cause: unknown) { function message(cause: unknown) {
+1 -1
View File
@@ -107,7 +107,7 @@ export function DialogMcp() {
if (!server || server.status.status === "pending") return if (!server || server.status.status === "pending") return
setLoading(name) setLoading(name)
const current = data.location.default() const current = data.location.default()
const input = { server: name, location: { directory: current.directory } } const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input) const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
void call.catch(toast.error).finally(() => setLoading(null)) void call.catch(toast.error).finally(() => setLoading(null))
} }
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useData } from "../context/data" import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference" import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const data = useData() const data = useData()
const dialog = useDialog() const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey)) const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected() const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item]))) const providers = createMemo(
const models = createMemo(() => data.location.model.list() ?? []) () => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID) const showExtra = createMemo(() => connected() && !props.providerID)
@@ -290,6 +290,7 @@ export function Autocomplete(props: {
limit: 20, limit: 20,
location: { location: {
directory: input.location?.directory, directory: input.location?.directory,
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
}, },
}) })
.then( .then(
+50 -51
View File
@@ -327,10 +327,6 @@ export function Prompt(props: PromptProps) {
if (!session) return if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id) if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID syncedSessionID = sessionID
}) })
@@ -943,15 +939,43 @@ export function Prompt(props: PromptProps) {
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selectedModel = local.model.current() const selection = local.model.selection()
if (!selectedModel) { if (!selection) {
void promptModelWarning() void promptModelWarning()
return false return false
} }
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
variant: "warning",
})
return false
}
const variant = local.model.variant.current() const variant = selection.variant
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
@@ -969,8 +993,8 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location, location: directory ? { directory } : location,
agent: agent.id, agent: agent.id,
model: { model: {
providerID: selectedModel.providerID, providerID: selection.providerID,
id: selectedModel.modelID, id: selection.modelID,
variant, variant,
}, },
}) })
@@ -990,17 +1014,6 @@ export function Prompt(props: PromptProps) {
session = created session = created
} }
const inputText = expandTrackedPastedText(
store.prompt.text,
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
const ref = store.extmarkToPart.get(extmark.id)
if (ref?.type !== "pasted") return []
const part = store.prompt.pasted[ref.index]
if (!part) return []
return [{ start: extmark.start, end: extmark.end, text: part.text }]
}),
)
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext() const editorSelection = editorContext()
@@ -1013,43 +1026,30 @@ export function Prompt(props: PromptProps) {
command: inputText, command: inputText,
}) })
setStore("mode", "normal") setStore("mode", "normal")
} else if ( } else if (slashHead && isCommand) {
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments const model = { providerID: selection.providerID, id: selection.modelID, variant }
const firstLineEnd = inputText.indexOf("\n") const cancelCommit = local.model.trackSessionCommit(sessionID, model)
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void client.api.session void client.api.session
.command({ .command({
sessionID, sessionID,
command: command.slice(1), command: slashHead.name,
arguments: args, arguments: slashHead.arguments,
agent: agent.id, agent: agent.id,
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, model,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
}) })
.catch((error) => { .catch((error) => {
cancelCommit()
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
}) })
} else if ( } else if (isSkill) {
inputText.startsWith("/") &&
(data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
)
) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: inputText.split("\n")[0].split(" ")[0].slice(1), skill: slashHead!.name,
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1061,13 +1061,15 @@ export function Prompt(props: PromptProps) {
await client.api.session.switchAgent({ sessionID, agent: agent.id }) await client.api.session.switchAgent({ sessionID, agent: agent.id })
} }
if ( if (
session?.model?.providerID !== selectedModel.providerID || session?.model?.providerID !== selection.providerID ||
session.model.id !== selectedModel.modelID || session.model.id !== selection.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default") (session.model.variant ?? "default") !== (variant ?? "default")
) { ) {
await client.api.session.switchModel({ const model = { providerID: selection.providerID, id: selection.modelID, variant }
sessionID, const cancelCommit = local.model.trackSessionCommit(sessionID, model)
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
}) })
} }
if (session?.revert) { if (session?.revert) {
@@ -1320,10 +1322,7 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"` return `Ask anything... "${list()[store.placeholder % list().length]}"`
})() })()
if (!value) return undefined if (!value) return undefined
const width = const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
dimensions().width < 44
? dimensions().width - 5
: Math.min(75, dimensions().width - 4) - 5
return Locale.takeWidth(value, Math.max(1, width)).trimEnd() return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
}) })
const locationLabel = createMemo(() => { const locationLabel = createMemo(() => {
+4 -3
View File
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory", description: "Share tabs globally or keep a separate set for each working directory",
}), }),
vertical: Schema.optional(Schema.Boolean).annotate({ layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
description: "Show tabs in a left sidebar instead of a horizontal strip", description: "Show tabs in a horizontal strip or vertical sidebar",
}), }),
}), }),
).annotate({ description: "Tab strip settings" }), ).annotate({ description: "Tab strip settings" }),
@@ -194,7 +194,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
tabs: { tabs: {
enabled: boolean enabled: boolean
scope: "global" | "cwd" scope: "global" | "cwd"
vertical?: boolean layout: "horizontal" | "vertical"
} }
} }
@@ -230,6 +230,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
...input.tabs, ...input.tabs,
enabled: input.tabs?.enabled ?? true, enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd", scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
}, },
} }
} }
+184 -80
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array" import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { batch, createMemo } from "solid-js" import { batch, createMemo, onCleanup } from "solid-js"
import { useEvent } from "./event" import { useEvent } from "./event"
import path from "path" import path from "path"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route" import { useRoute } from "./route"
import { useData } from "./data" import { useData } from "./data"
import { usePermission } from "./permission" import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) { export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/") const [providerID, ...rest] = model.split("/")
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
const event = useEvent() const event = useEvent()
const permission = usePermission() const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
const providers = () => data.location.provider.list(location.ref)
function isModelValid(model: ModelPreferenceModel) { function isModelValid(model: ModelPreferenceModel) {
return !!data.location.model return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
} }
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
for (const modelFn of modelFns) { for (const modelFn of modelFns) {
const model = modelFn() const model = modelFn()
if (!model) continue if (model && isModelValid(model)) return model
if (isModelValid(model)) return model
} }
} }
function createAgent() { function createAgent() {
const agents = createMemo(() => const agents = createMemo(() =>
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), (data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
) )
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({ const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined, current: undefined as string | undefined,
}) })
@@ -128,35 +132,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = createAgent() const agent = createAgent()
function createModel() { function createModel() {
const [modelStore, setModelStore] = createStore< type ModelSelection = ModelPreferenceModel & { variant?: string }
ModelPreference & { const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
ready: boolean
model: Record<string, ModelPreferenceModel>
}
>({
ready: false, ready: false,
model: {},
recent: [], recent: [],
favorite: [], favorite: [],
variant: {}, variant: {},
}) })
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const state = { const pendingSelectionCommits = new Map<string, string>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false, pending: false,
} }
function save() { function savePreferences() {
if (!modelStore.ready) { if (!preferences.ready) {
state.pending = true saveState.pending = true
return return
} }
state.pending = false saveState.pending = false
void repository void repository
.patch({ .patch({
recent: modelStore.recent, recent: preferences.recent,
favorite: modelStore.favorite, favorite: preferences.favorite,
variant: modelStore.variant, variant: preferences.variant,
}) })
.catch(() => undefined) .catch(() => undefined)
} }
@@ -164,14 +173,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
repository repository
.load() .load()
.then((value) => { .then((value) => {
setModelStore("recent", value.recent) setPreferences("recent", value.recent)
setModelStore("favorite", value.favorite) setPreferences("favorite", value.favorite)
setModelStore("variant", value.variant) setPreferences("variant", value.variant)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
setModelStore("ready", true) setPreferences("ready", true)
if (state.pending) save() if (saveState.pending) savePreferences()
}) })
const fallbackModel = createMemo(() => { const fallbackModel = createMemo(() => {
@@ -185,13 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
for (const item of modelStore.recent) { for (const item of preferences.recent) {
if (isModelValid(item)) { if (isModelValid(item)) {
return item return item
} }
} }
const model = data.location.model.list()?.[0] const model = models()?.[0]
if (!model) return undefined if (!model) return undefined
return { return {
providerID: model.providerID, providerID: model.providerID,
@@ -199,30 +208,134 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
}) })
const currentModel = createMemo(() => { const newSessionModel = createMemo(() => {
const a = agent.current() const a = agent.current()
return ( return getFirstValidModel(
getFirstValidModel( () => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
() => a && modelStore.model[a.id], () => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id }, fallbackModel,
fallbackModel,
) ?? undefined
) )
}) })
const currentSelection = createMemo<ModelSelection | undefined>(() => {
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
})
const currentModel = createMemo(() => {
const selection = currentSelection()
if (!selection) return
return { providerID: selection.providerID, modelID: selection.modelID }
})
function locationAgentKey(agentID: string) {
const ref = location.ref ?? data.location.default()
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
return {
providerID: model.providerID,
modelID: model.id,
variant: normalizeModelVariant(model.variant),
}
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
)
}
function selectModel(model: ModelPreferenceModel) {
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
if (!current) return false
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
return { return {
current: currentModel, current: currentModel,
selection: currentSelection,
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
trackSessionCommit(
sessionID: string,
value: {
providerID: string
id: string
variant?: string
},
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
pendingSelectionCommits.set(sessionID, committed)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
},
get ready() { get ready() {
return modelStore.ready return preferences.ready
},
get catalogReady() {
return models() !== undefined
}, },
recent() { recent() {
return modelStore.recent return preferences.recent
}, },
favorite() { favorite() {
return modelStore.favorite return preferences.favorite
}, },
parsed: createMemo(() => { parsed: createMemo(() => {
const value = currentModel() const value = currentSelection()
if (!value) { if (!value) {
return { return {
provider: "Connect a provider", provider: "Connect a provider",
@@ -230,33 +343,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false, reasoning: false,
} }
} }
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID) const provider = providers()?.find((item) => item.id === value.providerID)
const info = data.location.model const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID, model: info?.name ?? `${value.modelID} (unavailable)`,
reasoning: (info?.variants?.length ?? 0) !== 0, reasoning: (info?.variants?.length ?? 0) !== 0,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
const current = currentModel() const current = currentSelection()
if (!current) return if (!current) return
const recent = modelStore.recent const recent = recentModels(current, preferences.recent).filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
let next = index + direction
if (next < 0) next = recent.length - 1 if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0 if (next >= recent.length) next = 0
const val = recent[next] const val = recent[next]
if (!val) return if (!val) return
const a = agent.current() selectModel({ ...val })
if (!a) return
setModelStore("model", a.id, { ...val })
}, },
cycleFavorite(direction: 1 | -1) { cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item)) const favorites = preferences.favorite.filter((item) => isModelValid(item))
if (!favorites.length) { if (!favorites.length) {
toast.show({ toast.show({
variant: "info", variant: "info",
@@ -265,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
return return
} }
const current = currentModel() const current = currentSelection()
let index = -1 let index = -1
if (current) { if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
@@ -279,45 +387,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const next = favorites[index] const next = favorites[index]
if (!next) return if (!next) return
const a = agent.current() if (!selectModel({ ...next })) return
if (!a) return setPreferences("recent", recentModels(next, preferences.recent))
setModelStore("model", a.id, { ...next }) savePreferences()
setModelStore("recent", recentModels(next, modelStore.recent))
save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const a = agent.current() if (!selectModel(model)) return
if (!a) return
setModelStore("model", a.id, model)
if (options?.recent) { if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent)) setPreferences("recent", recentModels(model, preferences.recent))
save() savePreferences()
} }
}) })
}, },
toggleFavorite(model: { providerID: string; modelID: string }) { toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => { batch(() => {
if (!isModelValid(model)) return if (!isModelValid(model)) return
const exists = modelStore.favorite.some( const exists = preferences.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID, (x) => x.providerID === model.providerID && x.modelID === model.modelID,
) )
const next = exists const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) ? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite] : [model, ...preferences.favorite]
setModelStore( setPreferences(
"favorite", "favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
) )
save() savePreferences()
}) })
}, },
variant: { variant: {
selected() { selected() {
const m = currentModel() return currentSelection()?.variant
if (!m) return undefined
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
}, },
current() { current() {
const v = this.selected() const v = this.selected()
@@ -325,18 +427,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return undefined return undefined
}, },
list() { list() {
const m = currentModel() const m = currentSelection()
if (!m) return [] if (!m) return []
const info = data.location.model const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? [] return info?.variants?.map((variant) => variant.id) ?? []
}, },
set(value: string | undefined) { set(value: string | undefined) {
const m = currentModel() const m = currentSelection()
if (!m) return if (!m) return
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value)) if (route.data.type === "session") {
save() setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
return
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
savePreferences()
}, },
cycle() { cycle() {
const variants = this.list() const variants = this.list()
+1
View File
@@ -19,6 +19,7 @@ function location(ref: LocationRef) {
return { return {
location: { location: {
directory: ref.directory, directory: ref.directory,
workspace: ref.workspaceID,
}, },
} }
} }
+3 -1
View File
@@ -151,6 +151,7 @@ function formRequestOptions(location: LocationRef | undefined) {
return { return {
headers: { headers: {
"x-opencode-directory": encodeURIComponent(location.directory), "x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
}, },
} }
} }
@@ -235,7 +236,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
.find({ .find({
query, query,
type: "file", type: "file",
location: { directory: state.location.directory }, location: { directory: state.location.directory, workspace: state.location.workspaceID },
}) })
.then((result) => result.data.map((file) => file.path)) .then((result) => result.data.map((file) => file.path))
.catch(() => []), .catch(() => []),
@@ -650,6 +651,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
{ {
location: { location: {
directory: state.location.directory, directory: state.location.directory,
workspace: state.location.workspaceID,
}, },
}, },
{ signal: attempt.signal }, { signal: attempt.signal },
+2 -1
View File
@@ -421,6 +421,7 @@ async function resolveSelectedModel(
? { ? {
location: { location: {
directory: input.location.directory, directory: input.location.directory,
workspace: input.location.workspaceID,
}, },
} }
: undefined, : undefined,
@@ -818,7 +819,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.location input.location
? client.form.request.list( ? client.form.request.list(
{ {
location: { directory: input.location.directory }, location: { directory: input.location.directory, workspace: input.location.workspaceID },
}, },
options, options,
) )

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