mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 01:29:44 -04:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bb1fc34a5 | |||
| 9a5b015126 | |||
| dcae95e2bb | |||
| 38e7d2302c | |||
| e4c603d631 | |||
| 0cc507b8a9 | |||
| 727beae2d5 | |||
| eaa196d574 | |||
| cd64a17e37 | |||
| d35ca49c31 | |||
| d7ecb3af37 | |||
| 5cf97f1b96 | |||
| 13a415b68a | |||
| dee3f88737 | |||
| 39dab7199f | |||
| 4729a3f9ae | |||
| 9f05331ac4 | |||
| 65c6a71903 | |||
| a779027303 | |||
| 0b3732aa7c | |||
| 248bfb64d4 | |||
| 20fa444f31 | |||
| c66d84169a | |||
| cbec0989e0 | |||
| 7dbe8c4c13 | |||
| 8864b01d0b | |||
| ec95b27308 | |||
| be2f74d44a | |||
| 912a801060 | |||
| 0215498f63 | |||
| 1539bd6794 | |||
| 939da6a5e7 | |||
| 0f27ee7b4c | |||
| 9d284040de | |||
| 4e03ad95a7 | |||
| 8d70adab50 | |||
| 8b8e2d97dc | |||
| 716258d97a | |||
| d6ab618155 | |||
| 1fa3d9dd12 | |||
| 92442bb7ff | |||
| 64c82bedec | |||
| 6cbc71a2eb | |||
| 756ac88039 | |||
| a4eeaefd5b | |||
| 6e0066247c | |||
| 65ac161373 | |||
| 9c0883054b | |||
| 79eed3d3a0 | |||
| be74fad688 | |||
| 5d93a35a1d | |||
| 395eb8a9c8 | |||
| 14e7b547c5 | |||
| 97a5497664 | |||
| e544b8927b | |||
| 44481e1ecc | |||
| 5168652082 | |||
| 977a35c222 | |||
| 5d64dc8bfd | |||
| 479821e621 | |||
| 5ce6626a44 | |||
| eef60bcd64 | |||
| f840b3e3a2 | |||
| b1abfd4bd9 | |||
| 71251dd246 | |||
| 8461226ccd | |||
| e1b863dcb3 | |||
| 56405af7d6 | |||
| 05c3866788 | |||
| 3eca11a7da | |||
| 794283057a |
@@ -678,8 +678,10 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -1511,6 +1513,18 @@
|
||||
|
||||
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
|
||||
|
||||
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
|
||||
|
||||
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||
@@ -1719,6 +1733,10 @@
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||
@@ -1813,6 +1831,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
|
||||
@@ -3297,6 +3317,8 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -3537,6 +3559,10 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
|
||||
|
||||
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
@@ -4569,6 +4595,8 @@
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@@ -4795,6 +4823,8 @@
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
|
||||
@@ -4833,6 +4863,10 @@
|
||||
|
||||
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
|
||||
|
||||
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
|
||||
|
||||
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
|
||||
|
||||
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
|
||||
|
||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||
@@ -5631,6 +5665,8 @@
|
||||
|
||||
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
|
||||
|
||||
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -6589,6 +6625,8 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6733,6 +6771,8 @@
|
||||
|
||||
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
|
||||
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
|
||||
|
||||
@@ -178,6 +178,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelCompatibility {
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("llm constructors", () => {
|
||||
const updated = LanguageModel.update(base, {
|
||||
route: responsesRoute,
|
||||
defaults: { generation: { maxTokens: 20 } },
|
||||
compatibility: { toolSchema: "gemini" },
|
||||
compatibility: { toolSchema: "gemini", requireFinishReason: false },
|
||||
})
|
||||
const updatedInput = LanguageModel.input(updated)
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("llm constructors", () => {
|
||||
expect(String(updated.id)).toBe("fake-model")
|
||||
expect(updated.route).toBe(responsesRoute)
|
||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
|
||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||
expect(String(updatedInput.provider)).toBe("fake")
|
||||
|
||||
@@ -10,7 +10,6 @@ const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
needs_client_registration: "mcp.status.needs_client_registration",
|
||||
disabled: "mcp.status.disabled",
|
||||
} as const
|
||||
|
||||
@@ -57,7 +56,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
}
|
||||
const error = () => {
|
||||
const s = mcpStatus()
|
||||
if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error
|
||||
if (s?.status === "failed") return s.error
|
||||
}
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
|
||||
@@ -426,8 +426,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
"bg-icon-warning-base": status() === "needs_auth",
|
||||
}}
|
||||
/>
|
||||
<span class="flex flex-col min-w-0 flex-1">
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
test("detects MCP failures that do not block chatting", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -48,7 +47,6 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
describe("hasServiceNeedingAttention", () => {
|
||||
test("detects MCP states that need user attention", () => {
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
|
||||
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores states that do not need user attention", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LspStatus } from "@/types"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
|
||||
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
|
||||
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
|
||||
return input.mcp.some((status) => status === "needs_auth")
|
||||
}
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
|
||||
@@ -13,7 +13,6 @@ export async function toggleMcp(input: {
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "Show resolved configuration" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
@@ -137,6 +140,32 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "List all available models",
|
||||
params: ServerParams,
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to export to stdout"),
|
||||
Flag.optional,
|
||||
),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("import", {
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -22,6 +22,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { emitKeypressEvents, type Key } from "node:readline"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.export,
|
||||
Effect.fn("cli.export")(function* (input) {
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const requested = Option.getOrUndefined(input.session)
|
||||
const selected = requested
|
||||
? undefined
|
||||
: yield* Effect.promise(async () => {
|
||||
const location = await client.location.get({ location: { directory: process.cwd() } })
|
||||
const page = await client.session.list({
|
||||
directory: location.directory,
|
||||
parentID: null,
|
||||
order: "desc",
|
||||
limit: 50,
|
||||
})
|
||||
if (page.data.length === 0) {
|
||||
process.stderr.write(`No sessions found${EOL}`)
|
||||
return undefined
|
||||
}
|
||||
return selectSession(page.data, input.sanitize)
|
||||
})
|
||||
const sessionID = requested ?? selected?.session.id
|
||||
if (!sessionID) return
|
||||
const data = yield* Effect.promise(() =>
|
||||
client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
|
||||
)
|
||||
process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
|
||||
}),
|
||||
)
|
||||
|
||||
type Selection = { session: SessionInfo; sanitize: boolean }
|
||||
|
||||
function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
|
||||
if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
|
||||
const input = process.stdin
|
||||
const output = process.stderr
|
||||
const wasRaw = input.isRaw
|
||||
const wasPaused = input.isPaused()
|
||||
const date = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const columns = output.columns ?? 100
|
||||
const titleWidth = Math.max(8, Math.min(48, columns - 34))
|
||||
let selected = 0
|
||||
let offset = 0
|
||||
let sanitize = initialSanitize
|
||||
let height = 0
|
||||
|
||||
const render = () => {
|
||||
const visible = sessions.slice(offset, offset + 10)
|
||||
const lines = [" \x1b[36mExport session\x1b[0m", ""]
|
||||
lines.push(
|
||||
...visible.map((session) => {
|
||||
const index = sessions.indexOf(session)
|
||||
const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
|
||||
const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
|
||||
const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}`
|
||||
return index === selected ? `\x1b[1m${row}\x1b[0m` : row
|
||||
}),
|
||||
"",
|
||||
` [${sanitize ? "x" : " "}] sanitize sensitive data`,
|
||||
"",
|
||||
" navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m",
|
||||
)
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write(lines.join(EOL) + EOL)
|
||||
height = lines.length
|
||||
}
|
||||
const clear = () => {
|
||||
if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
|
||||
output.write("\x1b[?25h")
|
||||
input.removeListener("keypress", onKeypress)
|
||||
input.setRawMode(wasRaw ?? false)
|
||||
if (wasPaused) input.pause()
|
||||
}
|
||||
const onKeypress = (value: string | undefined, key: Key) => {
|
||||
if (key.name === "up") {
|
||||
selected = (selected - 1 + sessions.length) % sessions.length
|
||||
if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
|
||||
if (selected < offset) offset = selected
|
||||
}
|
||||
if (key.name === "down") {
|
||||
selected = (selected + 1) % sessions.length
|
||||
if (selected === 0) offset = 0
|
||||
if (selected >= offset + 10) offset = selected - 9
|
||||
}
|
||||
if (key.name === "space" || value === " ") sanitize = !sanitize
|
||||
if (key.name === "return") return finish(sessions[selected])
|
||||
if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
|
||||
render()
|
||||
}
|
||||
const finish = (session: SessionInfo) => {
|
||||
clear()
|
||||
resolveSelection?.({ session, sanitize })
|
||||
}
|
||||
const cancel = () => {
|
||||
clear()
|
||||
resolveSelection?.()
|
||||
}
|
||||
let resolveSelection: ((selection?: Selection) => void) | undefined
|
||||
|
||||
emitKeypressEvents(input)
|
||||
input.setRawMode(true)
|
||||
input.resume()
|
||||
input.on("keypress", onKeypress)
|
||||
output.write("\x1b[?25l")
|
||||
render()
|
||||
return new Promise<Selection | undefined>((resolve) => {
|
||||
resolveSelection = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
|
||||
const json = JSON.stringify(data, null, 2) + EOL
|
||||
if (stdout) return json
|
||||
const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
|
||||
await Bun.write(file, json)
|
||||
return file + EOL
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.import,
|
||||
Effect.fn("cli.import")(function* (input) {
|
||||
const text = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
input.file.startsWith("http://") || input.file.startsWith("https://")
|
||||
? fetch(input.file).then((response) => {
|
||||
if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
|
||||
return response.text()
|
||||
})
|
||||
: Bun.file(input.file).text(),
|
||||
catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
|
||||
})
|
||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
|
||||
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
const client = OpenCode.make({
|
||||
baseUrl: server.endpoint.url,
|
||||
headers: Service.headers(server.endpoint),
|
||||
})
|
||||
const location = yield* Effect.promise(() =>
|
||||
client.location.get({
|
||||
location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/session/import", server.endpoint.url), {
|
||||
method: "POST",
|
||||
headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...encoded,
|
||||
location: { directory: location.directory, workspaceID: location.workspaceID },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
if (response.status === 409) {
|
||||
process.stderr.write(`Session already exists${EOL}`)
|
||||
return
|
||||
}
|
||||
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
|
||||
const imported = yield* Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
|
||||
)(yield* Effect.promise(() => response.text()))
|
||||
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
|
||||
}),
|
||||
)
|
||||
@@ -34,7 +34,6 @@ function icon(status: McpServer["status"]) {
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
@@ -45,8 +44,6 @@ function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
|
||||
@@ -10,7 +10,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: serverURL,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -22,6 +22,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
config: () => import("./commands/handlers/debug/config"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
@@ -36,6 +37,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
import: () => import("./commands/handlers/import"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||
) =>
|
||||
resolveSessionTarget({
|
||||
client,
|
||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||
location: { directory: next.location.directory },
|
||||
agent: next.agent,
|
||||
model: next.model
|
||||
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||
|
||||
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
? Promise.resolve(undefined)
|
||||
: input.client.form.request
|
||||
.list({
|
||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
||||
location: { directory: input.location.directory },
|
||||
})
|
||||
.catch(() => undefined),
|
||||
])
|
||||
@@ -757,7 +757,6 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
|
||||
{
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent(location.directory),
|
||||
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
next.model ??
|
||||
(options.variant
|
||||
? await client.model
|
||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||
.default({ location: { directory: next.location.directory } })
|
||||
.then((result) => result.data)
|
||||
: undefined)
|
||||
const model = selected
|
||||
|
||||
@@ -42,9 +42,10 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
const options = yield* ServiceConfig.options()
|
||||
const mismatch = args.mismatch ?? "ignore"
|
||||
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })
|
||||
return {
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, mismatch),
|
||||
service: managedService(options),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
@@ -98,12 +98,12 @@ const paths = Effect.gen(function* () {
|
||||
}
|
||||
})
|
||||
|
||||
export const options = Effect.fnUntraced(function* () {
|
||||
export const options = Effect.fnUntraced(function* (input: { readonly checkVersion?: boolean } = {}) {
|
||||
const { file, legacyRegistrationFiles } = yield* paths
|
||||
yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
|
||||
return {
|
||||
file,
|
||||
version: OPENCODE_VERSION,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -136,7 +136,6 @@ async function latestSession(
|
||||
const page = await client.session.list(
|
||||
{
|
||||
directory: location.directory,
|
||||
workspace: location.workspaceID,
|
||||
parentID: null,
|
||||
limit: SESSION_PAGE_LIMIT,
|
||||
order: "desc",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
describe("debug config command", () => {
|
||||
test("is included in troubleshooting help", async () => {
|
||||
const [debug, config] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "config", "--help"])])
|
||||
|
||||
expect(debug.exitCode).toBe(0)
|
||||
expect(debug.stdout).toContain("config")
|
||||
expect(debug.stdout).toContain("Show resolved configuration")
|
||||
expect(config.exitCode).toBe(0)
|
||||
expect(config.stdout).toContain("opencode debug config [flags]")
|
||||
})
|
||||
|
||||
test("prints config entries from the invoking directory without reordering permissions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-config-"))
|
||||
const project = path.join(import.meta.dir, "..")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const entries = [
|
||||
{
|
||||
type: "document",
|
||||
path: path.join(project, "opencode.json"),
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file", path: path.join(project, "opencode.json") },
|
||||
]
|
||||
let requested: URL | undefined
|
||||
const authorization: Array<string | null> = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
requested = url
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, password: "secret" }),
|
||||
)
|
||||
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
|
||||
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual(entries)
|
||||
expect(requested?.pathname).toBe("/api/config")
|
||||
expect(requested?.searchParams.get("location[directory]")).toBe(project)
|
||||
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
|
||||
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { writeExport } from "../src/commands/handlers/export"
|
||||
|
||||
const info = {
|
||||
id: "ses_export_test",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Exported session",
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
const transfer = {
|
||||
info,
|
||||
messages: [
|
||||
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
||||
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
||||
],
|
||||
}
|
||||
const sanitizedTransfer = {
|
||||
info: {
|
||||
...info,
|
||||
title: "[redacted:session-title:ses_export_test]",
|
||||
location: { directory: "/[redacted:session-directory:ses_export_test]" },
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: "msg_first",
|
||||
type: "user",
|
||||
text: "[redacted:text:msg_first]",
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: "msg_second",
|
||||
type: "user",
|
||||
text: "[redacted:text:msg_second]",
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
|
||||
function run(args: string[], stdin?: string) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
|
||||
}
|
||||
|
||||
test("export is raw by default and supports explicit sanitization", async () => {
|
||||
const sanitization: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
||||
if (url.pathname === `/api/session/${info.id}/export`) {
|
||||
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
||||
return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
|
||||
const exported = JSON.parse(stdout)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(exported).toEqual(transfer)
|
||||
|
||||
const [sanitized, , sanitizedExitCode] = await run([
|
||||
"export",
|
||||
"-s",
|
||||
info.id,
|
||||
"--sanitize",
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
])
|
||||
expect(sanitizedExitCode).toBe(0)
|
||||
expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
|
||||
expect(sanitization).toEqual(["false", "true"])
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("export reports an empty session list without a stack trace", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: "/project",
|
||||
project: { id: "global", directory: "/project", canonical: "/project" },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`No sessions found${os.EOL}`)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("interactive export writes a temporary JSON file", async () => {
|
||||
const output = await writeExport(transfer, info.id, false)
|
||||
const file = output.trim()
|
||||
|
||||
try {
|
||||
expect(path.dirname(file)).toBe(os.tmpdir())
|
||||
expect(await Bun.file(file).json()).toEqual(transfer)
|
||||
} finally {
|
||||
await fs.rm(file, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("import validates a file and sends it to the resolved location", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
|
||||
const file = path.join(root, "session.json")
|
||||
await fs.writeFile(file, JSON.stringify(transfer))
|
||||
let imported: unknown
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
project: { id: "global", directory: root, canonical: root },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session/import") {
|
||||
imported = await request.json()
|
||||
return Response.json({ data: { ...info, location: { directory: root } } })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, , exitCode] = await run([
|
||||
"import",
|
||||
file,
|
||||
"--directory",
|
||||
root,
|
||||
"--server",
|
||||
server.url.toString(),
|
||||
])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
|
||||
expect(imported).toEqual({ ...transfer, location: { directory: root } })
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("import reports an existing session without a stack trace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
|
||||
const file = path.join(root, "session.json")
|
||||
await fs.writeFile(file, JSON.stringify(transfer))
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
project: { id: "global", directory: root, canonical: root },
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toBe("")
|
||||
expect(stderr).toBe(`Session already exists${os.EOL}`)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -426,14 +426,13 @@ describe("runNonInteractivePrompt", () => {
|
||||
const globalOptions = {
|
||||
headers: {
|
||||
"x-opencode-directory": "%2Fwork%20tree",
|
||||
"x-opencode-workspace": "wrk_1",
|
||||
},
|
||||
}
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||
location: { directory: "/work tree" },
|
||||
})
|
||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||
|
||||
@@ -55,3 +55,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("service options only require a matching version when requested", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-options-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
||||
|
||||
try {
|
||||
expect((await runPromise(ServiceConfig.options())).version).toBeUndefined()
|
||||
expect((await runPromise(ServiceConfig.options({ checkVersion: true }))).version).toBe(OPENCODE_VERSION)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
@@ -48,6 +49,7 @@ const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmi
|
||||
const effectTypeReferences = [
|
||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||
...namespaceTypes("Config", "@opencode-ai/schema/config", Config),
|
||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||
|
||||
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
|
||||
import type { Location } from "@opencode-ai/schema/location"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
|
||||
@@ -38,6 +38,7 @@ import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Config } from "@opencode-ai/schema/config"
|
||||
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
@@ -58,9 +59,7 @@ export interface ServerApi<E = never> {
|
||||
readonly get: ServerGetOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint2_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint2_0Output = Location.Info
|
||||
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||
|
||||
@@ -68,15 +67,13 @@ export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint3_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
|
||||
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
||||
|
||||
export type Endpoint3_1Input = {
|
||||
readonly agentID: Agent.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
|
||||
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
||||
@@ -86,9 +83,7 @@ export interface AgentApi<E = never> {
|
||||
readonly get: AgentGetOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint4_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
||||
|
||||
@@ -97,7 +92,6 @@ export interface PluginApi<E = never> {
|
||||
}
|
||||
|
||||
export type Endpoint5_0Input = {
|
||||
readonly workspace?: Workspace.ID | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
@@ -126,42 +120,54 @@ export type Endpoint5_1Input = {
|
||||
export type Endpoint5_1Output = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
|
||||
|
||||
export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
|
||||
export type Endpoint5_2Input = {
|
||||
readonly info: Session.Info
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly location?: Location.Ref | undefined
|
||||
}
|
||||
export type Endpoint5_2Output = Session.Info
|
||||
export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
|
||||
|
||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_3Output = Session.Info
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||
export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
|
||||
export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
|
||||
export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
|
||||
|
||||
export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_4Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
||||
export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
|
||||
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_6Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_7Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = {
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
}
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_10Input = {
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -171,10 +177,10 @@ export type Endpoint5_10Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_10Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type Endpoint5_12Output = SessionPending.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -186,19 +192,19 @@ export type Endpoint5_11Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_11Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
export type Endpoint5_13Output = SessionPending.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -207,81 +213,81 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: "steer" | "queue" | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_15Output = SessionPending.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||
export type Endpoint5_15Output = SessionPending.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_16Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
|
||||
export type Endpoint5_17Output = SessionPending.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_19Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_17Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
export type Endpoint5_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_22Input,
|
||||
) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = {
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
|
||||
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_23Output = void
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_23Input,
|
||||
) => Effect.Effect<Endpoint5_23Output, E>
|
||||
input: Endpoint5_25Input,
|
||||
) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_24Output = void
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_24Input,
|
||||
) => Effect.Effect<Endpoint5_24Output, E>
|
||||
input: Endpoint5_26Input,
|
||||
) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_25Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_27Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_26Input = {
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_26Output =
|
||||
export type Endpoint5_28Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -849,23 +855,25 @@ export type Endpoint5_26Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_29Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_31Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
readonly import: SessionImportOperation<E>
|
||||
readonly export: SessionExportOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
@@ -918,15 +926,11 @@ export interface MessageApi<E = never> {
|
||||
readonly list: MessageListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint7_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
|
||||
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
||||
|
||||
export type Endpoint7_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
|
||||
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
||||
|
||||
@@ -936,7 +940,7 @@ export interface ModelApi<E = never> {
|
||||
}
|
||||
|
||||
export type Endpoint8_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly prompt: string
|
||||
readonly model?: Model.Ref | undefined
|
||||
}
|
||||
@@ -947,15 +951,13 @@ export interface GenerateApi<E = never> {
|
||||
readonly text: GenerateTextOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint9_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
|
||||
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
||||
|
||||
export type Endpoint9_1Input = {
|
||||
readonly providerID: Provider.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
|
||||
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||
@@ -965,21 +967,19 @@ export interface ProviderApi<E = never> {
|
||||
readonly get: ProviderGetOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint10_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
|
||||
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
||||
|
||||
export type Endpoint10_1Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
|
||||
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||
|
||||
export type Endpoint10_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly url: string
|
||||
}
|
||||
export type Endpoint10_2Output = void
|
||||
@@ -989,7 +989,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
|
||||
|
||||
export type Endpoint10_3Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly key: string
|
||||
readonly label?: string | undefined
|
||||
}
|
||||
@@ -1000,7 +1000,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
|
||||
|
||||
export type Endpoint10_4Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly inputs: { readonly [x: string]: string }
|
||||
readonly label?: string | undefined
|
||||
@@ -1013,7 +1013,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
|
||||
export type Endpoint10_5Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly attemptID: Integration.AttemptID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
|
||||
export type IntegrationOauthStatusOperation<E = never> = (
|
||||
@@ -1023,7 +1023,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
|
||||
export type Endpoint10_6Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly attemptID: Integration.AttemptID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly code?: string | undefined
|
||||
}
|
||||
export type Endpoint10_6Output = void
|
||||
@@ -1034,7 +1034,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
|
||||
export type Endpoint10_7Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly attemptID: Integration.AttemptID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_7Output = void
|
||||
export type IntegrationOauthCancelOperation<E = never> = (
|
||||
@@ -1043,7 +1043,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
|
||||
|
||||
export type Endpoint10_8Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly label?: string | undefined
|
||||
}
|
||||
@@ -1055,7 +1055,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
|
||||
export type Endpoint10_9Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly attemptID: Integration.AttemptID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
|
||||
export type IntegrationCommandStatusOperation<E = never> = (
|
||||
@@ -1065,7 +1065,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
|
||||
export type Endpoint10_10Input = {
|
||||
readonly integrationID: Integration.ID
|
||||
readonly attemptID: Integration.AttemptID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint10_10Output = void
|
||||
export type IntegrationCommandCancelOperation<E = never> = (
|
||||
@@ -1090,15 +1090,13 @@ export interface IntegrationApi<E = never> {
|
||||
}
|
||||
}
|
||||
|
||||
export type Endpoint11_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
|
||||
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||
|
||||
export type Endpoint11_1Input = {
|
||||
readonly server: string
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
|
||||
}
|
||||
export type Endpoint11_1Output = void
|
||||
@@ -1106,28 +1104,26 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
|
||||
|
||||
export type Endpoint11_2Input = {
|
||||
readonly server: string
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint11_2Output = void
|
||||
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
||||
|
||||
export type Endpoint11_3Input = {
|
||||
readonly server: string
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint11_3Output = void
|
||||
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
||||
|
||||
export type Endpoint11_4Input = {
|
||||
readonly server: string
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint11_4Output = void
|
||||
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
||||
|
||||
export type Endpoint11_5Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
|
||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
||||
|
||||
@@ -1142,7 +1138,7 @@ export interface McpApi<E = never> {
|
||||
|
||||
export type Endpoint12_0Input = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly label: string
|
||||
}
|
||||
export type Endpoint12_0Output = void
|
||||
@@ -1150,7 +1146,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
|
||||
|
||||
export type Endpoint12_1Input = {
|
||||
readonly credentialID: Credential.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint12_1Output = void
|
||||
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||
@@ -1163,15 +1159,13 @@ export interface CredentialApi<E = never> {
|
||||
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
|
||||
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
|
||||
|
||||
export type Endpoint13_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint13_1Output = Project.Current
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
export type Endpoint13_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint13_2Output = Project.Directories
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||
@@ -1182,9 +1176,7 @@ export interface ProjectApi<E = never> {
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint14_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
|
||||
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
||||
|
||||
@@ -1228,9 +1220,7 @@ export interface FormApi<E = never> {
|
||||
readonly cancel: FormCancelOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint15_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
|
||||
export type PermissionRequestListOperation<E = never> = (
|
||||
input?: Endpoint15_0Input,
|
||||
@@ -1288,14 +1278,14 @@ export interface PermissionApi<E = never> {
|
||||
}
|
||||
|
||||
export type Endpoint16_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly path?: RelativePath | undefined
|
||||
}
|
||||
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||
|
||||
export type Endpoint16_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory" | undefined
|
||||
readonly limit?: number | undefined
|
||||
@@ -1308,9 +1298,7 @@ export interface FileApi<E = never> {
|
||||
readonly find: FileFindOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint17_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
|
||||
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
||||
|
||||
@@ -1318,9 +1306,7 @@ export interface CommandApi<E = never> {
|
||||
readonly list: CommandListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint18_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
|
||||
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
||||
|
||||
@@ -1335,14 +1321,12 @@ export interface EventApi<E = never> {
|
||||
readonly subscribe: EventSubscribeOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint20_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
|
||||
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
||||
|
||||
export type Endpoint20_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly command?: string | undefined
|
||||
readonly args?: ReadonlyArray<string> | undefined
|
||||
readonly cwd?: string | undefined
|
||||
@@ -1354,14 +1338,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
|
||||
|
||||
export type Endpoint20_2Input = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
|
||||
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||
|
||||
export type Endpoint20_3Input = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly title?: string | undefined
|
||||
readonly size?: { readonly rows: number; readonly cols: number } | undefined
|
||||
}
|
||||
@@ -1370,7 +1354,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
|
||||
|
||||
export type Endpoint20_4Input = {
|
||||
readonly ptyID: Pty.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint20_4Output = void
|
||||
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
||||
@@ -1383,14 +1367,12 @@ export interface PtyApi<E = never> {
|
||||
readonly remove: PtyRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint21_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
|
||||
|
||||
export type Endpoint21_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly command: string
|
||||
readonly cwd?: string | undefined
|
||||
readonly timeout: number
|
||||
@@ -1401,14 +1383,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
|
||||
|
||||
export type Endpoint21_2Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
|
||||
|
||||
export type Endpoint21_3Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly timeout: number
|
||||
}
|
||||
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
|
||||
@@ -1416,7 +1398,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
|
||||
|
||||
export type Endpoint21_4Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly cursor?: number | undefined
|
||||
readonly limit?: number | undefined
|
||||
}
|
||||
@@ -1433,7 +1415,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
|
||||
|
||||
export type Endpoint21_5Input = {
|
||||
readonly id: Shell.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint21_5Output = void
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
|
||||
@@ -1447,9 +1429,7 @@ export interface ShellApi<E = never> {
|
||||
readonly remove: ShellRemoveOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint22_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||
export type QuestionRequestListOperation<E = never> = (
|
||||
input?: Endpoint22_0Input,
|
||||
@@ -1478,9 +1458,7 @@ export interface QuestionApi<E = never> {
|
||||
readonly reject: QuestionRejectOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
|
||||
@@ -1490,7 +1468,7 @@ export interface ReferenceApi<E = never> {
|
||||
|
||||
export type Endpoint24_0Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly strategy: ProjectCopy.StrategyID
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
@@ -1500,7 +1478,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
@@ -1509,7 +1487,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
@@ -1520,20 +1498,16 @@ export interface ProjectCopyApi<E = never> {
|
||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export type Endpoint25_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
@@ -1549,9 +1523,7 @@ export interface VcsApi<E = never> {
|
||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export type Endpoint26_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint26_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||
|
||||
@@ -1576,14 +1548,12 @@ export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
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 Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
@@ -1595,6 +1565,14 @@ export interface WebsearchApi<E = never> {
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
@@ -1625,4 +1603,5 @@ export interface AppApi<E = never> {
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
readonly websearch: WebsearchApi<E>
|
||||
readonly config: ConfigApi<E>
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ import type {
|
||||
Endpoint5_0Output,
|
||||
Endpoint5_1Input,
|
||||
Endpoint5_1Output,
|
||||
Endpoint5_2Input,
|
||||
Endpoint5_2Output,
|
||||
Endpoint5_3Input,
|
||||
Endpoint5_3Output,
|
||||
Endpoint5_4Input,
|
||||
Endpoint5_4Output,
|
||||
Endpoint5_5Input,
|
||||
Endpoint5_5Output,
|
||||
@@ -76,6 +76,10 @@ import type {
|
||||
Endpoint5_28Output,
|
||||
Endpoint5_29Input,
|
||||
Endpoint5_29Output,
|
||||
Endpoint5_30Input,
|
||||
Endpoint5_30Output,
|
||||
Endpoint5_31Input,
|
||||
Endpoint5_31Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -220,6 +224,8 @@ import type {
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -286,7 +292,6 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
||||
preserveEffect<Endpoint5_0Output>()(
|
||||
raw["session.list"]({
|
||||
query: {
|
||||
workspace: input?.["workspace"],
|
||||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
@@ -315,9 +320,11 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||
const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
|
||||
preserveEffect<Endpoint5_2Output>()(
|
||||
raw["session.active"]({}).pipe(
|
||||
raw["session.import"]({
|
||||
payload: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -325,20 +332,23 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
|
||||
|
||||
const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
|
||||
preserveEffect<Endpoint5_3Output>()(
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
|
||||
const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
|
||||
preserveEffect<Endpoint5_4Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||
preserveEffect<Endpoint5_5Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -346,35 +356,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
||||
|
||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||
preserveEffect<Endpoint5_6Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||
preserveEffect<Endpoint5_7Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -392,8 +415,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -413,16 +436,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -439,29 +462,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -471,35 +494,19 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -507,29 +514,45 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveStream<Endpoint5_26Output>()(
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveStream<Endpoint5_28Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -541,18 +564,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -562,30 +585,32 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
|
||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint5_0(raw),
|
||||
create: Endpoint5_1(raw),
|
||||
active: Endpoint5_2(raw),
|
||||
get: Endpoint5_3(raw),
|
||||
remove: Endpoint5_4(raw),
|
||||
fork: Endpoint5_5(raw),
|
||||
switchAgent: Endpoint5_6(raw),
|
||||
switchModel: Endpoint5_7(raw),
|
||||
rename: Endpoint5_8(raw),
|
||||
move: Endpoint5_9(raw),
|
||||
prompt: Endpoint5_10(raw),
|
||||
command: Endpoint5_11(raw),
|
||||
skill: Endpoint5_12(raw),
|
||||
synthetic: Endpoint5_13(raw),
|
||||
shell: Endpoint5_14(raw),
|
||||
compact: Endpoint5_15(raw),
|
||||
wait: Endpoint5_16(raw),
|
||||
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
|
||||
context: Endpoint5_20(raw),
|
||||
pending: { list: Endpoint5_21(raw) },
|
||||
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
|
||||
generate: Endpoint5_25(raw),
|
||||
log: Endpoint5_26(raw),
|
||||
interrupt: Endpoint5_27(raw),
|
||||
background: Endpoint5_28(raw),
|
||||
message: Endpoint5_29(raw),
|
||||
import: Endpoint5_2(raw),
|
||||
export: Endpoint5_3(raw),
|
||||
active: Endpoint5_4(raw),
|
||||
get: Endpoint5_5(raw),
|
||||
remove: Endpoint5_6(raw),
|
||||
fork: Endpoint5_7(raw),
|
||||
switchAgent: Endpoint5_8(raw),
|
||||
switchModel: Endpoint5_9(raw),
|
||||
rename: Endpoint5_10(raw),
|
||||
move: Endpoint5_11(raw),
|
||||
prompt: Endpoint5_12(raw),
|
||||
command: Endpoint5_13(raw),
|
||||
skill: Endpoint5_14(raw),
|
||||
synthetic: Endpoint5_15(raw),
|
||||
shell: Endpoint5_16(raw),
|
||||
compact: Endpoint5_17(raw),
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
pending: { list: Endpoint5_23(raw) },
|
||||
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
|
||||
generate: Endpoint5_27(raw),
|
||||
log: Endpoint5_28(raw),
|
||||
interrupt: Endpoint5_29(raw),
|
||||
background: Endpoint5_30(raw),
|
||||
message: Endpoint5_31(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
@@ -1241,6 +1266,13 @@ const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
server: adaptGroup1(raw["server.server"]),
|
||||
@@ -1271,6 +1303,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
AppApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
@@ -20,6 +21,7 @@ export type {
|
||||
} from "./api.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Config } from "@opencode-ai/schema/config"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
@@ -53,6 +53,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -82,6 +83,18 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const registration = yield* registered(options.file, true)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
timeouts = {
|
||||
info,
|
||||
count: timeouts !== undefined && same(timeouts.info, info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* evict(info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -182,6 +195,10 @@ type LocalService = {
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
return (yield* probeResult(info, allowLegacy)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -189,39 +206,53 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined) return undefined
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && health.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: yield* probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, allowLegacy)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
@@ -249,6 +280,19 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = yield* requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -2,6 +2,7 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
export type ConfigApi = Client["config"]
|
||||
export type EventApi = Client["event"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type ModelApi = Client["model"]
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
SessionListOutput,
|
||||
SessionCreateInput,
|
||||
SessionCreateOutput,
|
||||
SessionImportInput,
|
||||
SessionImportOutput,
|
||||
SessionExportInput,
|
||||
SessionExportOutput,
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
@@ -216,6 +220,8 @@ import type {
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -442,7 +448,6 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session`,
|
||||
query: {
|
||||
workspace: input?.["workspace"],
|
||||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
@@ -471,7 +476,31 @@ export function make(options: ClientOptions) {
|
||||
location: input?.["location"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionImportOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/import`,
|
||||
body: { info: input["info"], messages: input["messages"], location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionExportOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
|
||||
query: { sanitize: input["sanitize"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1811,6 +1840,20 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
config: {
|
||||
get: (input?: ConfigGetInput, requestOptions?: RequestOptions) =>
|
||||
request<ConfigGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/config`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
ConfigApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
|
||||
@@ -34,6 +34,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
@@ -62,6 +63,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count:
|
||||
timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = 5_000
|
||||
@@ -145,6 +159,10 @@ type LocalService = {
|
||||
}
|
||||
|
||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||
return (await probeResult(info, allowLegacy)).service
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, allowLegacy = false) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -152,30 +170,48 @@ async function probe(info: Info, allowLegacy = false): Promise<LocalService | un
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
const signal = AbortSignal.timeout(2_000)
|
||||
const result = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (body.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||
if (body.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && body.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true }
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: await probe(info, allowLegacy) }
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(await probeResult(info, allowLegacy)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
@@ -209,6 +245,18 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function evict(info: Info, options: { readonly file?: string }) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (await waitUntilStopped(info.pid)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = await requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -10,6 +11,7 @@ const Client = await import("../src/effect")
|
||||
|
||||
test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Agent).toBe(Agent)
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,10 @@ const server = Bun.serve({
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "hanging") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
if (mode === "modern" && requests === 1) {
|
||||
await writeFile(registration + ".first-request", "")
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
|
||||
@@ -70,6 +70,32 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = Bun.spawn([process.execPath, fixture, registration, "hanging"], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
})
|
||||
processes.push(existing)
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
})
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
@@ -33,6 +33,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
"config",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -50,6 +51,34 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
let request: Request | undefined
|
||||
const entries = [
|
||||
{
|
||||
type: "document" as const,
|
||||
path: "/tmp/project/opencode.json",
|
||||
info: {
|
||||
permissions: [
|
||||
{ action: "shell", resource: "*", effect: "ask" as const },
|
||||
{ action: "shell", resource: "git status", effect: "allow" as const },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "file" as const, path: "/tmp/project/opencode.json" },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return Response.json(entries)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.config.get({ location: { directory: "/tmp/project" } })).toEqual(entries)
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -70,6 +70,30 @@ test("reports a failed registered service without spawning", async () => {
|
||||
expect(process.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "hanging")
|
||||
await waitForFile(registration)
|
||||
const original = await Bun.file(registration).json()
|
||||
|
||||
const endpoint = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
}, 20_000)
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -1021,13 +1021,12 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
await Effect.runPromise(
|
||||
location
|
||||
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
|
||||
.execute({ location: { directory: "/tmp" } })
|
||||
.pipe(Effect.provide(client.layer)),
|
||||
)
|
||||
|
||||
const url = new URL(client.requests[0]!.url)
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
|
||||
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
|
||||
})
|
||||
|
||||
test("serializes supported simple and form parameter shapes", async () => {
|
||||
|
||||
+70
-105
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
|
||||
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd",
|
||||
"prevIds": [
|
||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
||||
"4d283c23-533f-4b87-9aa4-218f7fd36bca"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account_state",
|
||||
"entityType": "tables"
|
||||
@@ -75,84 +71,8 @@
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "type",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''",
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "branch",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "extra",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_used",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
@@ -1385,18 +1305,63 @@
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_workspace_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "provider",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "binding",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "root",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_updated",
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
@@ -1564,15 +1529,6 @@
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -1690,6 +1646,15 @@
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
|
||||
+56
-164
@@ -5,8 +5,16 @@ import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory,
|
||||
Document,
|
||||
File,
|
||||
Info,
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
@@ -15,141 +23,10 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
import { ConfigMCP } from "./config/mcp"
|
||||
import { ConfigModel } from "./config/model"
|
||||
import { ConfigPlugin } from "./config/plugin"
|
||||
import { ConfigProvider } from "./config/provider"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigWebSearch } from "./config/websearch"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigWarming } from "./config/warming"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
import { ConfigNormalize } from "./config/normalize"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
description: "JSON schema reference for configuration validation",
|
||||
}),
|
||||
shell: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default shell to use for terminal and shell tool execution",
|
||||
}),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
|
||||
description: "Default model to use when no session or agent model is selected",
|
||||
}),
|
||||
default_agent: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Automatically update or notify when a new version is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
}),
|
||||
enterprise: Schema.Struct({
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
.pipe(Schema.optional)
|
||||
.annotate({
|
||||
description: "Enterprise sharing service configuration",
|
||||
}),
|
||||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named built-in agent overrides and custom agent definitions",
|
||||
}),
|
||||
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Enable snapshots used for undo and revert behavior",
|
||||
}),
|
||||
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
|
||||
description: "Filesystem watcher configuration",
|
||||
}),
|
||||
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in formatters or configure formatter overrides",
|
||||
}),
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
}),
|
||||
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
|
||||
description: "MCP server configuration",
|
||||
}),
|
||||
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
|
||||
description: "Conversation compaction behavior",
|
||||
}),
|
||||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
references: ConfigReference.Info.pipe(Schema.optional).annotate({
|
||||
description: "Named local directories or Git repositories available as external context",
|
||||
}),
|
||||
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
|
||||
description: "Web search provider selection",
|
||||
}),
|
||||
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
|
||||
description: "Ordered plugin enablement directives and external package declarations",
|
||||
}),
|
||||
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
|
||||
description: "Keep recently active sessions warm with transient model requests (default: false)",
|
||||
}),
|
||||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
experimental: ConfigExperimental.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
type: Schema.Literal("directory"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.ClaudeDirectory")({
|
||||
type: Schema.Literal("claude"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
@@ -215,24 +92,43 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
||||
const parseInfo = (text: string) => {
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
return Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: decodeInfo(input),
|
||||
if (errors.length) {
|
||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: "$",
|
||||
kind: "invalid",
|
||||
action: "rejected malformed JSON or JSONC document",
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = ConfigNormalize.normalize(input)
|
||||
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
|
||||
Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
|
||||
kind: diagnostic.kind,
|
||||
action: diagnostic.message,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (result.type === "rejected") return
|
||||
const info = Option.getOrUndefined(decodeInfo(result.encoded))
|
||||
if (info) return info
|
||||
yield* Effect.logWarning("configuration normalization diagnostic", {
|
||||
source,
|
||||
path: "$",
|
||||
kind: "invalid",
|
||||
action: "rejected canonical configuration after final validation",
|
||||
})
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const text = yield* fs.readFileStringSafe(filepath)
|
||||
if (!text) return
|
||||
if (text === undefined) return
|
||||
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||
const info = parseInfo(substituted)
|
||||
const info = yield* parseInfo(substituted, filepath)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
@@ -263,7 +159,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
text: JSON.stringify(config),
|
||||
env: variables,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, entry.origin)),
|
||||
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
|
||||
),
|
||||
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
|
||||
@@ -296,21 +192,17 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...((yield* fs.isDir(globalClaudeDirectory))
|
||||
? [new ClaudeDirectory({ type: "claude", path: globalClaudeDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".claude")
|
||||
.map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...((yield* fs.isDir(globalAgentsDirectory))
|
||||
? [new AgentsDirectory({ type: "agents", path: globalAgentsDirectory })]
|
||||
: []),
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".agents")
|
||||
.map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })),
|
||||
]
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
@@ -344,14 +236,14 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
Effect.orDie,
|
||||
)
|
||||
: []
|
||||
const content = options?.content
|
||||
const content = options?.content !== undefined
|
||||
? yield* ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
dir: location.directory,
|
||||
text: options.content,
|
||||
}).pipe(
|
||||
Effect.map(parseInfo),
|
||||
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
|
||||
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
@@ -359,13 +251,13 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...(supplementary[0] ?? []),
|
||||
...explicit,
|
||||
...direct,
|
||||
...supplementary.slice(1).flat(),
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...content,
|
||||
]
|
||||
})
|
||||
@@ -408,7 +300,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { ConfigModel } from "./model"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,12 +0,0 @@
|
||||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
export * as ConfigCompaction from "./compaction"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
export * as ConfigExperimental from "./experimental"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { ConfigPolicy } from "./policy"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
|
||||
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
|
||||
description: "Maximum subagent nesting depth. Defaults to 1.",
|
||||
}),
|
||||
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Ordered policies controlling access to configured resources",
|
||||
}),
|
||||
}) {}
|
||||
@@ -1,15 +0,0 @@
|
||||
export * as ConfigMedia from "./media"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -0,0 +1,796 @@
|
||||
export * as ConfigNormalize from "./normalize"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
||||
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
||||
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { ConfigAgentV1 } from "../v1/config/agent"
|
||||
import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
||||
import { ConfigCommandV1 } from "../v1/config/command"
|
||||
import { ConfigMCPV1 } from "../v1/config/mcp"
|
||||
import { ConfigPermissionV1 } from "../v1/config/permission"
|
||||
import { ConfigPluginV1 } from "../v1/config/plugin"
|
||||
import { ConfigProviderV1 } from "../v1/config/provider"
|
||||
import { ConfigMigrateV1 } from "../v1/config/migrate"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly kind: "conflict" | "invalid" | "unsupported"
|
||||
readonly path: readonly string[]
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export type Result =
|
||||
| {
|
||||
readonly type: "normalized"
|
||||
readonly encoded: Readonly<Record<string, unknown>>
|
||||
readonly diagnostics: readonly Diagnostic[]
|
||||
}
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
"batch_tool",
|
||||
"openTelemetry",
|
||||
"primary_tools",
|
||||
"continue_loop_on_deny",
|
||||
] as const
|
||||
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
||||
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
||||
|
||||
export function normalize(input: unknown): Result {
|
||||
if (!isRecord(input))
|
||||
return {
|
||||
type: "rejected",
|
||||
diagnostics: [
|
||||
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
||||
],
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = []
|
||||
const encoded: Record<string, unknown> = {}
|
||||
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
||||
|
||||
const legacySnapshots = own(input, "snapshot")
|
||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
: undefined
|
||||
: undefined
|
||||
const legacyMedia = own(input, "attachment")
|
||||
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
||||
: undefined
|
||||
if (legacyMedia !== undefined) {
|
||||
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
legacyReferences,
|
||||
nativeReferences,
|
||||
isRecord(input.reference) || isRecord(input.references),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
migratedCommands,
|
||||
nativeCommands,
|
||||
isRecord(input.command) || isRecord(input.commands),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"agents",
|
||||
migratedAgents,
|
||||
nativeAgents,
|
||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
legacyProviders,
|
||||
nativeProviders,
|
||||
isRecord(input.provider) || isRecord(input.providers),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
normalizeSkills(input, encoded, diagnostics)
|
||||
normalizeMcp(input, encoded, diagnostics)
|
||||
normalizeCompaction(input, encoded, diagnostics)
|
||||
normalizeExperimental(input, encoded, diagnostics)
|
||||
normalizeWatcher(input, encoded, diagnostics)
|
||||
normalizeFormatter(input, encoded, diagnostics)
|
||||
normalizeLsp(input, encoded, diagnostics)
|
||||
|
||||
const nativeAtomic = {
|
||||
$schema: Info.fields.$schema,
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
autoupdate: Info.fields.autoupdate,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
snapshots: Info.fields.snapshots,
|
||||
media: Info.fields.media,
|
||||
tool_output: Info.fields.tool_output,
|
||||
websearch: Info.fields.websearch,
|
||||
warming: Info.fields.warming,
|
||||
}
|
||||
Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
||||
if (!own(input, key)) return
|
||||
const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
||||
if (value === undefined) return
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
}
|
||||
|
||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "skills")) return
|
||||
if (Array.isArray(input.skills)) {
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
invalid(["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
const legacyServers: Record<string, unknown> = {}
|
||||
const nativeServers: Record<string, unknown> = {}
|
||||
const timeout: Record<string, unknown> = {}
|
||||
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
||||
const value = decodeEncoded(
|
||||
PositiveInt,
|
||||
input.experimental.mcp_timeout,
|
||||
["experimental", "mcp_timeout"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) {
|
||||
timeout.catalog = value
|
||||
timeout.execution = value
|
||||
}
|
||||
}
|
||||
if (own(input, "mcp")) {
|
||||
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
||||
if (isRecord(input.mcp)) {
|
||||
Object.entries(input.mcp).forEach(([name, value]) => {
|
||||
const path = ["mcp", name]
|
||||
if (isEnabledOnlyMcp(value)) {
|
||||
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
||||
normalizeMcpTimeout(value, timeout, path, diagnostics)
|
||||
return
|
||||
}
|
||||
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
||||
if (server !== undefined)
|
||||
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
||||
})
|
||||
}
|
||||
}
|
||||
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
||||
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
||||
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
||||
return
|
||||
}
|
||||
encoded.mcp = {
|
||||
...(Object.keys(timeout).length ? { timeout } : {}),
|
||||
...(Object.keys(servers).length ? { servers } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMcpTimeout(
|
||||
value: unknown,
|
||||
timeout: Record<string, unknown>,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||
if (Object.keys(value).length && !recognized.length) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
recognized.forEach((key) => {
|
||||
const leaf = decodeEncoded(
|
||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||
value[key],
|
||||
[...path, key],
|
||||
diagnostics,
|
||||
)
|
||||
if (leaf === undefined) return
|
||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeCompaction(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, "compaction")) return
|
||||
if (!isRecord(input.compaction)) {
|
||||
invalid(["compaction"], diagnostics)
|
||||
return
|
||||
}
|
||||
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
||||
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
||||
const result: Record<string, unknown> = {}
|
||||
if (own(input.compaction, "auto")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigCompaction.Info.fields.auto,
|
||||
input.compaction.auto,
|
||||
["compaction", "auto"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.auto = value
|
||||
}
|
||||
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Keep.fields.tokens,
|
||||
input.compaction.preserve_recent_tokens,
|
||||
["compaction", "preserve_recent_tokens"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
||||
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
||||
const nativeTokens =
|
||||
nativeKeep && own(nativeKeep, "tokens")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Keep.fields.tokens,
|
||||
nativeKeep.tokens,
|
||||
["compaction", "keep", "tokens"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
||||
if (tokens !== undefined) result.keep = { tokens }
|
||||
const legacyBuffer = own(input.compaction, "reserved")
|
||||
? decodeEncoded(
|
||||
ConfigCompaction.Info.fields.buffer,
|
||||
input.compaction.reserved,
|
||||
["compaction", "reserved"],
|
||||
diagnostics,
|
||||
)
|
||||
: undefined
|
||||
const nativeBuffer = own(input.compaction, "buffer")
|
||||
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
||||
: undefined
|
||||
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
||||
if (buffer !== undefined) result.buffer = buffer
|
||||
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
||||
}
|
||||
|
||||
function normalizeExperimental(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const result: Record<string, unknown> = {}
|
||||
const generated: unknown[] = []
|
||||
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
||||
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
||||
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
||||
generated.push(
|
||||
...enabled.values.map((resource) => ({
|
||||
action: "provider.use",
|
||||
resource: ConfigMigrateV1.providerID(resource),
|
||||
effect: "allow",
|
||||
})),
|
||||
)
|
||||
}
|
||||
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
||||
generated.push(
|
||||
...disabled.values.map((resource) => ({
|
||||
action: "provider.use",
|
||||
resource: ConfigMigrateV1.providerID(resource),
|
||||
effect: "deny",
|
||||
})),
|
||||
)
|
||||
const native: unknown[] = []
|
||||
if (own(input, "experimental")) {
|
||||
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
||||
if (isRecord(input.experimental)) {
|
||||
const experimental = input.experimental
|
||||
unsupportedExperimental.forEach((key) =>
|
||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
||||
)
|
||||
if (own(experimental, "subagent_depth")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_depth,
|
||||
experimental.subagent_depth,
|
||||
["experimental", "subagent_depth"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
||||
result.policies = [...generated, ...native]
|
||||
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
||||
encoded.experimental = result
|
||||
}
|
||||
|
||||
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "watcher")) return
|
||||
if (!isRecord(input.watcher)) {
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
function normalizeFormatter(
|
||||
input: Record<string, unknown>,
|
||||
encoded: Record<string, unknown>,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, "formatter")) return
|
||||
if (typeof input.formatter === "boolean") {
|
||||
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
|
||||
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "lsp")) return
|
||||
if (typeof input.lsp === "boolean") {
|
||||
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return []
|
||||
if (!isRecord(value)) {
|
||||
invalid(["tools"], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(value).flatMap(([action, raw]) => {
|
||||
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
||||
if (enabled === undefined) return []
|
||||
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
||||
})
|
||||
}
|
||||
|
||||
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return []
|
||||
if (typeof value === "string") {
|
||||
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
||||
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
invalid(["permission"], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(value).flatMap(([action, raw]) => {
|
||||
if (typeof raw === "string") {
|
||||
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
||||
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
invalid(["permission", action], diagnostics)
|
||||
return []
|
||||
}
|
||||
return Object.entries(raw).flatMap(([resource, effect], index) => {
|
||||
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
||||
return decoded === undefined
|
||||
? []
|
||||
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(["provider"], diagnostics)
|
||||
return {}
|
||||
}
|
||||
const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
||||
const path = ["provider", name]
|
||||
diagnoseProviderUnsupported(raw, path, diagnostics)
|
||||
if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
||||
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
||||
if (provider === undefined) return []
|
||||
const destination = ConfigMigrateV1.providerID(name)
|
||||
return [
|
||||
{
|
||||
name,
|
||||
destination,
|
||||
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
||||
},
|
||||
]
|
||||
})
|
||||
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
||||
const result: Record<string, unknown> = {}
|
||||
candidates.forEach((item) => {
|
||||
if (item.name !== item.destination && current.has(item.destination)) return
|
||||
setOwn(result, item.destination, item.provider)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value) || !isRecord(value.options)) return false
|
||||
const headersInvalid =
|
||||
own(value.options, "headers") &&
|
||||
(!isPlainRecord(value.options.headers) ||
|
||||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
||||
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
||||
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
||||
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
||||
return headersInvalid || bodyInvalid
|
||||
}
|
||||
|
||||
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
||||
if (!isRecord(value.models)) return
|
||||
Object.entries(value.models).forEach(([name, model]) => {
|
||||
if (!isRecord(model)) return
|
||||
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
||||
if (own(model, "status") && model.status !== "deprecated")
|
||||
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
||||
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
||||
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
Object.entries(value).forEach(([name, agent]) => {
|
||||
if (!isRecord(agent)) return
|
||||
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
||||
diagnoseSelection(agent, [...path, name], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!isRecord(value)) return
|
||||
Object.entries(value).forEach(([name, entry]) => {
|
||||
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
||||
})
|
||||
}
|
||||
|
||||
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
||||
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
||||
if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
path: [...path, "model"],
|
||||
message: "omitted unsupported legacy model reference",
|
||||
})
|
||||
if (
|
||||
own(value, "variant") &&
|
||||
typeof value.variant === "string" &&
|
||||
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
||||
)
|
||||
diagnostics.push({
|
||||
kind: "unsupported",
|
||||
path: [...path, "variant"],
|
||||
message: "omitted unsupported legacy model variant",
|
||||
})
|
||||
}
|
||||
|
||||
function decodeProviderList(
|
||||
input: Record<string, unknown>,
|
||||
key: "enabled_providers" | "disabled_providers",
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
||||
if (!Array.isArray(input[key])) {
|
||||
invalid([key], diagnostics)
|
||||
return { present: true, nonEmpty: true, values: [] as string[] }
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {} as Record<string, S["Type"]>
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
) as Record<string, S["Type"]>
|
||||
}
|
||||
|
||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Encoded"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Encoded"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Type"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Type"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||
if (Option.isSome(decoded)) return decoded.value
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
||||
if (Option.isNone(decoded)) {
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
||||
if (Option.isSome(encoded)) return plain(encoded.value)
|
||||
invalid(path, diagnostics)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
||||
return plain(
|
||||
Option.getOrThrow(
|
||||
Schema.decodeUnknownOption(
|
||||
schema,
|
||||
options,
|
||||
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function plain(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(plain)
|
||||
if (!isRecord(value)) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
||||
)
|
||||
}
|
||||
|
||||
function mergeMap(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
legacy: Readonly<Record<string, unknown>>,
|
||||
native: Readonly<Record<string, unknown>>,
|
||||
present: boolean,
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const merged = mergeMaps(legacy, native, [key], diagnostics)
|
||||
if (present) target[key] = merged
|
||||
}
|
||||
|
||||
function mergeMaps(
|
||||
legacy: Readonly<Record<string, unknown>>,
|
||||
native: Readonly<Record<string, unknown>>,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
const result = Object.fromEntries(Object.entries(legacy))
|
||||
Object.entries(native).forEach(([name, value]) => {
|
||||
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
||||
setOwn(result, name, value)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(input).flatMap(([key, value]) => {
|
||||
const mapped = map(value)
|
||||
return mapped === undefined ? [] : [[key, mapped]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function overlay(
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
||||
target[key] = value
|
||||
}
|
||||
|
||||
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (native === undefined) return legacy
|
||||
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
||||
return native
|
||||
}
|
||||
|
||||
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
||||
if (!own(value, key)) return
|
||||
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
||||
}
|
||||
|
||||
function invalid(path: string[], diagnostics: Diagnostic[]) {
|
||||
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
||||
}
|
||||
|
||||
function conflict(path: string[], diagnostics: Diagnostic[]) {
|
||||
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
||||
}
|
||||
|
||||
function isDirectLegacyMcp(value: unknown) {
|
||||
return isRecord(value) && (value.type === "local" || value.type === "remote")
|
||||
}
|
||||
|
||||
function isEnabledOnlyMcp(value: unknown) {
|
||||
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (!isRecord(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function own(value: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(value, key)
|
||||
}
|
||||
|
||||
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
||||
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
@@ -24,7 +25,7 @@ const legacySources = [
|
||||
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
@@ -63,13 +64,13 @@ export const Plugin = define({
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
documents.filter((document): document is Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Config.Document[] }
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
@@ -139,7 +140,7 @@ export const Plugin = define({
|
||||
|
||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||
// check: directory-level events such as renames carry no per-file paths.
|
||||
function isAgentSource(entries: Config.Entry[], file: string) {
|
||||
function isAgentSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -208,5 +209,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
||||
return new Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Command } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
@@ -27,7 +28,7 @@ export const Plugin = define({
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
@@ -75,7 +76,7 @@ const sourceDirectories = ["command", "commands"] as const
|
||||
|
||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||
// directory-level events such as renames carry no per-file paths.
|
||||
function isCommandSource(entries: Config.Entry[], file: string) {
|
||||
function isCommandSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigPolicyPlugin from "./policy"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Wildcard } from "../../util/wildcard"
|
||||
@@ -13,7 +14,7 @@ export const Plugin = define({
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
for (const record of catalog.provider.list()) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
@@ -107,8 +108,8 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function configuredProviders(entries: readonly Config.Entry[]) {
|
||||
function configuredProviders(entries: readonly Entry[]) {
|
||||
return entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((file) => Object.entries(file.info.providers ?? {}))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -19,7 +20,7 @@ export const Plugin = define({
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
settings: JsonRecord.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: JsonRecord.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Money.USDPerMillionTokens,
|
||||
output: Money.USDPerMillionTokens,
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
context: Schema.Int.pipe(Schema.optional),
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
modelID: ID.pipe(Schema.optional),
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
compatibility: Compatibility.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
capabilities: Capabilities.pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
limit: Limit.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,9 +0,0 @@
|
||||
export * as ConfigToolOutput from "./tool-output"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
|
||||
max_lines: PositiveInt.pipe(Schema.optional),
|
||||
max_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * as ConfigWatcher from "./watcher"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Watcher")({
|
||||
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
@@ -42,5 +42,7 @@ export const migrations = (
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260806150823_remove_workspace"),
|
||||
import("./migration/20260806150824_add_workspace"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
@@ -4,19 +4,6 @@ import type { DatabaseMigration } from "./migration"
|
||||
export default {
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`workspace\` (
|
||||
\`id\` text PRIMARY KEY,
|
||||
\`type\` text NOT NULL,
|
||||
\`name\` text DEFAULT '' NOT NULL,
|
||||
\`branch\` text,
|
||||
\`directory\` text,
|
||||
\`extra\` text,
|
||||
\`project_id\` text NOT NULL,
|
||||
\`time_used\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`account_state\` (
|
||||
\`id\` integer PRIMARY KEY,
|
||||
@@ -216,6 +203,16 @@ export default {
|
||||
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 INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Formatter } from "./formatter"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
/** Lexical path for entry operations; remove unlinks the name, not the referent. */
|
||||
readonly absolute: 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 {
|
||||
readonly target: Target
|
||||
readonly content: string | Uint8Array
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface TextWriteInput {
|
||||
readonly target: Target
|
||||
export interface MoveInput {
|
||||
readonly from: Target
|
||||
readonly to: Target
|
||||
/** Text for the destination; may differ from the source content. */
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
@@ -26,74 +52,208 @@ export interface WriteResult {
|
||||
readonly target: string
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
/** Final text on disk after BOM handling and formatting. */
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
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>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/**
|
||||
* Write `content` to `to` with write semantics, then remove `from`. The
|
||||
* 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") {}
|
||||
|
||||
/** 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
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* 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(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withTargetLock =
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
||||
|
||||
const writeResult = (target: Target, existed: boolean): WriteResult => ({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
const formatter = yield* Formatter.Service
|
||||
return make({
|
||||
readOptional: (path) =>
|
||||
fs.readFile(path).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))),
|
||||
isDirectory: (path) =>
|
||||
fs.stat(path).pipe(
|
||||
Effect.map((info) => info.type === "Directory"),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
write: (path, content) => fs.writeWithDirs(path, content),
|
||||
remove: (path) =>
|
||||
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] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Formatter.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.
|
||||
*/
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
||||
|
||||
+244
-45
@@ -7,7 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { PositiveInt, RelativePath } from "./schema"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
|
||||
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||
|
||||
export const ReadInput = Schema.Struct({
|
||||
@@ -32,7 +34,24 @@ export type ListInput = typeof ListInput.Type
|
||||
export { FindInput }
|
||||
|
||||
export const DEFAULT_SEARCH_LIMIT = 100
|
||||
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
|
||||
/** One second of headroom over the hosted provider-side ripgrep kill. */
|
||||
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")({
|
||||
pattern: Schema.String,
|
||||
@@ -47,71 +66,251 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
|
||||
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
|
||||
|
||||
/** 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 {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly glob: (input: 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") {}
|
||||
|
||||
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(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.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 resolve = Effect.fnUntraced(function* (input?: RelativePath) {
|
||||
const absolute = path.resolve(location.directory, input ?? ".")
|
||||
if (!FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return { absolute, real, directory: location.directory, root }
|
||||
})
|
||||
return Service.of({
|
||||
find: search.find,
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
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)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
const entries = makeEntries(location, root, {
|
||||
paths: path,
|
||||
contains: FSUtil.contains,
|
||||
realPath: (target) => fs.realPath(target).pipe(Effect.orDie),
|
||||
readFile: (real) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* fs.stat(real).pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
return yield* fs.readFile(real).pipe(Effect.orDie)
|
||||
}),
|
||||
listDir: (real) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* fs.stat(real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
return Service.of({ find: search.find, ...searches, ...entries })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: baseLayer,
|
||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node, Ripgrep.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],
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as LocationWatcher from "./location-watcher"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -41,7 +42,7 @@ const layer = Layer.effect(
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const config = (yield* configService.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
const home = Protected.isHome(location.directory)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const prefixes = {
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "./location"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
@@ -25,12 +26,12 @@ export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literal("non_directory_ancestor"),
|
||||
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
|
||||
}) {}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
@@ -46,7 +47,13 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
||||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
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 externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
@@ -74,9 +81,56 @@ interface ResolvedPath {
|
||||
| "Socket"
|
||||
| "Unknown"
|
||||
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(
|
||||
Service,
|
||||
@@ -88,34 +142,10 @@ const layer = Layer.effect(
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||
const existing = yield* notFound(fs.realPath(absolute))
|
||||
if (existing !== undefined) {
|
||||
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 resolvePath = resolvePathWith({
|
||||
paths: path,
|
||||
realPathOptional: (target) => notFound(fs.realPath(target)),
|
||||
statOptional: (target) => notFound(fs.stat(target)),
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
@@ -126,19 +156,21 @@ const layer = Layer.effect(
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
const external = !lexicallyInternal
|
||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||
const externalDirectory =
|
||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
const resource = external
|
||||
? FSUtil.slash(absolute)
|
||||
: FSUtil.slash(path.relative(location.directory, absolute) || ".")
|
||||
const externalDirectory = resolved.lexicalDirectory
|
||||
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
canonical: resolved.canonical,
|
||||
absolute,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
save: FSUtil.slash(
|
||||
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||
),
|
||||
}
|
||||
@@ -155,3 +187,47 @@ export const node = makeLocationNode({
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
|
||||
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
|
||||
// rules. Hosted paths are never external: everything outside the Location is
|
||||
// rejected instead of routed to external_directory approval, because the
|
||||
// approval boundary vocabulary is host-relative.
|
||||
const hostedLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const env = yield* WorkspaceEnvironment.Service
|
||||
const location = yield* Location.Service
|
||||
// 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],
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FileMutation } from "./file-mutation"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Image } from "./image"
|
||||
@@ -47,6 +48,8 @@ import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { Tool } from "./tool"
|
||||
import { Vcs } from "./vcs"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
@@ -104,6 +107,27 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
|
||||
export type LocationServices = LayerNode.Output<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(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
@@ -118,7 +142,10 @@ export function buildLocationServiceMap(
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const startedAt = performance.now()
|
||||
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
|
||||
const workspaceID = ref.workspaceID
|
||||
const allReplacements = replacements.concat(
|
||||
workspaceID ? hostedReplacements(ref, workspaceID) : [[Location.node, Location.boundNode(ref)]],
|
||||
)
|
||||
// Apply replacements during hoist, not afterward: replacements can
|
||||
// introduce new tagged dependencies (Location.boundNode depends on
|
||||
// Project), and the hoist walk is the only pass that can still slice
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Project } from "./project"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
@@ -14,6 +17,12 @@ export interface Interface extends Info {
|
||||
|
||||
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)
|
||||
|
||||
const layer = (ref: Ref) =>
|
||||
@@ -37,3 +46,26 @@ export const boundNode = (ref: Ref) =>
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
||||
/**
|
||||
* Hosted Locations state their Project instead of discovering it: host git
|
||||
* and filesystem walks must never run against a provider directory. The
|
||||
* Workspace root comes from the already-connected environment, avoiding a
|
||||
* second workspace row read per graph build.
|
||||
*/
|
||||
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const env = yield* WorkspaceEnvironment.Service
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID,
|
||||
project: Project.hostedGlobal(env.directory),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [WorkspaceEnvironment.node],
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
|
||||
@@ -3,11 +3,12 @@ export * as MCP from "./index"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Config } from "../config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { Bus } from "../bus"
|
||||
import { Form } from "../form"
|
||||
@@ -178,7 +179,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprot
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WebSearchPreference } from "../websearch-preference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
@@ -90,8 +88,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const websearchPreference = yield* WebSearchPreference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* Skill.Service
|
||||
@@ -123,8 +119,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(WebSearchPreference.Service, websearchPreference),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(Skill.Service, skill),
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
}),
|
||||
)
|
||||
|
||||
function sessionHook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
): Promise<Registration>
|
||||
function sessionHook(
|
||||
...registration: {
|
||||
[Name in keyof SessionHooks]: [
|
||||
name: Name,
|
||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
||||
]
|
||||
}[keyof SessionHooks]
|
||||
) {
|
||||
if (registration[0] !== "http")
|
||||
return register(
|
||||
host.session.hook(registration[0], (event) =>
|
||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const output: SessionHttp = {
|
||||
...event,
|
||||
use: (item) => {
|
||||
middlewares.push(item)
|
||||
},
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.forEach(
|
||||
middlewares,
|
||||
(item) =>
|
||||
event.use((input, next) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => {
|
||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
||||
return Promise.resolve(
|
||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
||||
return Effect.runPromiseWith(
|
||||
context,
|
||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const context2: Context = {
|
||||
app: host.app,
|
||||
options: host.options,
|
||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: sessionHook,
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -221,18 +221,18 @@ export const OpenAIPlugin = define({
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
evt.use((request, next) => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
||||
const url = new URL(request.url)
|
||||
request.headers.set("originator", "opencode")
|
||||
request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return next(request)
|
||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
@@ -9,7 +10,6 @@ import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigPlugin } from "../config/plugin"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
@@ -39,7 +39,6 @@ import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WebSearchPreference } from "../websearch-preference"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
@@ -84,15 +83,15 @@ function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Config.Entry[]) {
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Config.Directory => entry.type === "directory"),
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
@@ -209,7 +208,7 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Config.Entry[], file: string) {
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
@@ -244,7 +243,7 @@ const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Config.Entry[],
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
@@ -283,7 +282,9 @@ const layer = Layer.effect(
|
||||
})
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
@@ -339,14 +340,12 @@ export const node = makeLocationNode({
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WebSearchPreference.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -44,6 +44,18 @@ export interface Resolved {
|
||||
readonly vcs?: Vcs
|
||||
}
|
||||
|
||||
/**
|
||||
* The stated Project for hosted Locations: global identity until rediscovery
|
||||
* inside the Workspace finds a repository. Canonical "/" matches the local
|
||||
* non-VCS fallback on posix hosts, so persisting it never repoints the global
|
||||
* row at a provider path.
|
||||
*/
|
||||
export const hostedGlobal = (root: string): Resolved => ({
|
||||
id: ID.global,
|
||||
directory: AbsolutePath.make(root),
|
||||
canonical: AbsolutePath.make("/"),
|
||||
})
|
||||
|
||||
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||
export const root = Effect.fn("Project.root")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as PtyTicket from "./ticket"
|
||||
|
||||
import { Workspace } from "../workspace"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { PtyID } from "./schema"
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
@@ -14,7 +13,6 @@ export const ConnectToken = PtyTicket.ConnectToken
|
||||
export type Scope = {
|
||||
readonly ptyID: PtyID
|
||||
readonly directory?: string
|
||||
readonly workspaceID?: Workspace.ID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -25,9 +23,7 @@ export interface Interface {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
|
||||
|
||||
function matches(record: Scope, input: Scope) {
|
||||
return (
|
||||
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
|
||||
)
|
||||
return record.ptyID === input.ptyID && record.directory === input.directory
|
||||
}
|
||||
|
||||
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
|
||||
|
||||
+246
-175
@@ -2,11 +2,13 @@ export * as Ripgrep from "./ripgrep"
|
||||
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { Command } from "effect/unstable/process/ChildProcess"
|
||||
import { Entry, Match } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { RipgrepBinary } from "./ripgrep/binary"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
|
||||
/**
|
||||
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
||||
@@ -18,6 +20,13 @@ import { RipgrepBinary } from "./ripgrep/binary"
|
||||
const ERROR_BYTES = 8 * 1024
|
||||
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({
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
@@ -90,184 +99,208 @@ const failure = (message: string, cause?: unknown) => new Error({ message, cause
|
||||
const isInvalidPattern = (stderr: string) =>
|
||||
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(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const process = yield* AppProcess.Service
|
||||
const binary = yield* RipgrepBinary.Service
|
||||
|
||||
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 pattern?: string
|
||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||
}) => {
|
||||
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,
|
||||
})),
|
||||
})
|
||||
return make({
|
||||
spawn: (command) => process.spawn(command),
|
||||
awaitTruncated: false,
|
||||
command: (input) =>
|
||||
binary.filepath.pipe(
|
||||
Effect.map((executable) =>
|
||||
ChildProcess.make(executable, input.args, {
|
||||
cwd: input.cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -275,4 +308,42 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
|
||||
export const node = makeLocationNode({ service: Service, 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],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project"
|
||||
import { Workspace } from "./workspace"
|
||||
import { Model } from "./model"
|
||||
import { Location } from "./location"
|
||||
import { SessionMessage } from "./session/message"
|
||||
@@ -27,7 +26,8 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error"
|
||||
import { Workspace } from "./workspace"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { SessionEvent } from "./session/event"
|
||||
@@ -58,12 +58,9 @@ import { fileURLToPath } from "url"
|
||||
|
||||
// - by project
|
||||
// - by subpath
|
||||
// - by workspace (home is special)
|
||||
|
||||
export { ListAnchor }
|
||||
|
||||
const ListInputBase = {
|
||||
workspaceID: Workspace.ID.pipe(Schema.optional),
|
||||
search: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
@@ -153,7 +150,9 @@ export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
readonly data: SessionSchema.Info[]
|
||||
}>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly create: (
|
||||
input: CreateInput,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
|
||||
readonly fork: (
|
||||
input: ForkInput,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
||||
@@ -199,7 +198,6 @@ export interface Interface {
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
@@ -235,7 +233,7 @@ export interface Interface {
|
||||
id?: Event.ID
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError>
|
||||
readonly skill: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -280,6 +278,7 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const projects = yield* Project.Service
|
||||
const workspaces = yield* Workspace.Service
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
@@ -291,6 +290,17 @@ const layer = Layer.effect(
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
// Host Project discovery must not run against provider directories; hosted
|
||||
// Sessions state the global Project until rediscovery inside the Workspace.
|
||||
const hostedProject = Effect.fn("Session.hostedProject")(function* (
|
||||
workspaceID: Workspace.ID,
|
||||
directory: AbsolutePath,
|
||||
) {
|
||||
const workspace = yield* workspaces.get(workspaceID)
|
||||
if (!FSUtil.containsPosix(workspace.root, directory))
|
||||
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
|
||||
return Project.hostedGlobal(workspace.root)
|
||||
})
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
@@ -328,7 +338,9 @@ const layer = Layer.effect(
|
||||
const location = parent?.location ?? input.location
|
||||
if (location === undefined)
|
||||
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
|
||||
const project = yield* projects.resolve(location.directory)
|
||||
const project = location.workspaceID
|
||||
? yield* hostedProject(location.workspaceID, location.directory)
|
||||
: yield* projects.resolve(location.directory)
|
||||
yield* persistProject(project)
|
||||
const projected = yield* bus
|
||||
.publish(
|
||||
@@ -340,7 +352,9 @@ const layer = Layer.effect(
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
subpath: RelativePath.make(
|
||||
FSUtil.slash(Location.paths(location).relative(project.directory, location.directory)),
|
||||
),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
@@ -427,7 +441,6 @@ const layer = Layer.effect(
|
||||
const sortColumn = SessionTable.time_updated
|
||||
const conditions: SQL[] = []
|
||||
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
|
||||
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
|
||||
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
@@ -701,10 +714,10 @@ const layer = Layer.effect(
|
||||
const expanded =
|
||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
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)))
|
||||
if (!info) return yield* new DestinationNotFoundError({ 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)
|
||||
yield* persistProject(project)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
@@ -715,9 +728,9 @@ const layer = Layer.effect(
|
||||
SessionEvent.Moved,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
location: Location.Ref.make({ directory }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
subpath: RelativePath.make(FSUtil.slash(path.relative(project.directory, directory))),
|
||||
},
|
||||
{ location: current.location },
|
||||
)
|
||||
@@ -1013,6 +1026,7 @@ export const node = makeGlobalNode({
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Project.node,
|
||||
Workspace.node,
|
||||
SessionExecution.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
import { Bus } from "../bus"
|
||||
@@ -19,7 +20,7 @@ import type { Info } from "../model"
|
||||
import { SessionUsage } from "./usage"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 8_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
@@ -148,9 +149,9 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
return ""
|
||||
}
|
||||
|
||||
const settings = (documents: readonly Config.Entry[]) => {
|
||||
const settings = (documents: readonly Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SessionErrors from "./error"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
@@ -18,6 +19,19 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
|
||||
"Session.WorkspaceDirectoryError",
|
||||
{
|
||||
workspaceID: WorkspaceID,
|
||||
directory: Schema.String,
|
||||
root: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
|
||||
const options: StreamOptions = {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
let latest = request
|
||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||
const middlewares: SessionHttpMiddleware[] = []
|
||||
const web = yield* HttpClientRequest.toWeb(request)
|
||||
yield* hooks.trigger("session", "http", {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
use: (item) =>
|
||||
Effect.sync(() => {
|
||||
middlewares.push(item)
|
||||
}),
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
const send = (input: Request) =>
|
||||
Effect.gen(function* () {
|
||||
let sent = HttpClientRequest.fromWeb(input)
|
||||
if (input.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
||||
input.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
latest = sent
|
||||
const response = yield* handler(sent)
|
||||
const body = [204, 205, 304].includes(response.status)
|
||||
? null
|
||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
||||
origins.set(output, sent)
|
||||
return output
|
||||
})
|
||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
||||
(next, item) => (input: Request) => item(input, next),
|
||||
send,
|
||||
)
|
||||
const response = yield* dispatch(web)
|
||||
const origin = origins.get(response) ?? latest
|
||||
return HttpClientResponse.fromWeb(origin, response)
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
|
||||
@@ -7,16 +7,16 @@ import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionPending } from "./pending"
|
||||
import { Workspace } from "../workspace"
|
||||
import { InstructionState } from "./instruction-state"
|
||||
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { Slug } from "../util/slug"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { SessionSchema } from "./schema"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import { Workspace } from "../workspace"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
@@ -376,13 +376,6 @@ const layer = Layer.effectDiscard(
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
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) =>
|
||||
@@ -393,7 +386,7 @@ const layer = Layer.effectDiscard(
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
workspace_id: null,
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
export * as SessionTransfer from "./transfer"
|
||||
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app"
|
||||
import { Bus } from "../bus"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { Project } from "../project"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { Session } from "../session"
|
||||
import { Slug } from "../util/slug"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionProjector } from "./projector"
|
||||
import { SessionMessageTable, SessionTable } from "./sql"
|
||||
|
||||
export const Data = SessionTransfer.Data
|
||||
export type Data = SessionTransfer.Data
|
||||
|
||||
export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
|
||||
"SessionTransfer.ImportConflictError",
|
||||
{ sessionID: Session.ID },
|
||||
) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly export: (input: {
|
||||
sessionID: Session.ID
|
||||
sanitize?: boolean
|
||||
}) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
|
||||
readonly import: (input: {
|
||||
data: Data
|
||||
location: Location.Ref
|
||||
}) => Effect.Effect<Session.Info, ImportConflictError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const app = yield* App.Metadata
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const projects = yield* Project.Service
|
||||
const sessions = yield* Session.Service
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
||||
const data = {
|
||||
info: yield* sessions.get(input.sessionID),
|
||||
messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
|
||||
}
|
||||
return input.sanitize ? sanitize(data) : data
|
||||
}),
|
||||
import: Effect.fn("SessionTransfer.import")(function* (input) {
|
||||
const sessionID = input.data.info.id
|
||||
const recorded = yield* db
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (recorded) return yield* new ImportConflictError({ sessionID })
|
||||
const project = yield* projects.resolve(input.location.directory)
|
||||
yield* persistProject(project)
|
||||
const messages = input.data.messages.map((message, index) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id: _, type, ...data } = encoded
|
||||
return {
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type,
|
||||
seq: index + 1,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
}
|
||||
})
|
||||
yield* bus
|
||||
.publish(
|
||||
SessionEvent.Created,
|
||||
{
|
||||
sessionID,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
location: input.location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
|
||||
title: input.data.info.title,
|
||||
agent: input.data.info.agent,
|
||||
model: input.data.info.model,
|
||||
},
|
||||
{
|
||||
location: input.location,
|
||||
commit: (seq) =>
|
||||
Effect.gen(function* () {
|
||||
if (messages.length > 0) {
|
||||
yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
|
||||
yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
|
||||
}
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: input.data.info.cost,
|
||||
tokens_input: input.data.info.tokens.input,
|
||||
tokens_output: input.data.info.tokens.output,
|
||||
tokens_reasoning: input.data.info.tokens.reasoning,
|
||||
tokens_cache_read: input.data.info.tokens.cache.read,
|
||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
||||
time_archived: input.data.info.time.archived
|
||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
||||
: null,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionProjector.SessionAlreadyProjected
|
||||
? Effect.fail(new ImportConflictError({ sessionID }))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
return yield* sessions.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
|
||||
})
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
}
|
||||
|
||||
function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
|
||||
if (!value) return value
|
||||
return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
|
||||
}
|
||||
|
||||
function sanitize(data: Data): Data {
|
||||
return {
|
||||
info: {
|
||||
...data.info,
|
||||
title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
|
||||
location: {
|
||||
...data.info.location,
|
||||
directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
|
||||
},
|
||||
revert: data.info.revert
|
||||
? {
|
||||
...data.info.revert,
|
||||
files: data.info.revert.files?.map((file, index) => ({
|
||||
...file,
|
||||
file: redact("revert-file", String(index), file.file),
|
||||
patch: redact("revert-patch", String(index), file.patch),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
messages: data.messages.map(sanitizeMessage),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
const meta = metadata("message-metadata", message.id, message.metadata)
|
||||
if (message.type === "user")
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
text: redact("text", message.id, message.text),
|
||||
files: message.files?.map((file, index) => ({
|
||||
...file,
|
||||
data: "",
|
||||
source: { type: "inline" },
|
||||
name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
|
||||
description:
|
||||
file.description === undefined ? undefined : redact("file-description", String(index), file.description),
|
||||
mention: file.mention
|
||||
? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
|
||||
: undefined,
|
||||
})),
|
||||
agents: message.agents?.map((agent, index) => ({
|
||||
...agent,
|
||||
name: redact("agent-name", String(index), agent.name),
|
||||
mention: agent.mention
|
||||
? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
if (message.type === "synthetic")
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
text: redact("synthetic", message.id, message.text),
|
||||
description:
|
||||
message.description === undefined
|
||||
? undefined
|
||||
: redact("synthetic-description", message.id, message.description),
|
||||
}
|
||||
if (message.type === "system")
|
||||
return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
|
||||
if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
|
||||
if (message.type === "shell")
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
command: redact("shell-command", message.id, message.command),
|
||||
output: message.output
|
||||
? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
|
||||
: undefined,
|
||||
}
|
||||
if (message.type === "assistant")
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
content: message.content.map((content) => {
|
||||
if (content.type === "text")
|
||||
return {
|
||||
...content,
|
||||
text: redact("text", message.id, content.text),
|
||||
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
|
||||
}
|
||||
if (content.type === "reasoning")
|
||||
return {
|
||||
...content,
|
||||
text: redact("reasoning", message.id, content.text),
|
||||
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
|
||||
}
|
||||
return {
|
||||
...content,
|
||||
providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
|
||||
providerResultState: content.providerResultState
|
||||
? { redacted: `tool-provider-result-state:${message.id}` }
|
||||
: undefined,
|
||||
state: sanitizeToolState(message.id, content.state),
|
||||
}
|
||||
}),
|
||||
}
|
||||
if (message.type === "compaction") {
|
||||
if (message.status === "failed")
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
metadata: meta,
|
||||
summary: redact("compaction-summary", message.id, message.summary),
|
||||
recent: redact("compaction-recent", message.id, message.recent),
|
||||
}
|
||||
}
|
||||
return { ...message, metadata: meta }
|
||||
}
|
||||
|
||||
function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
|
||||
if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
|
||||
if (state.status === "running")
|
||||
return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
|
||||
const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
|
||||
if (state.status === "completed")
|
||||
return {
|
||||
...state,
|
||||
input: { redacted: `tool-input:${id}` },
|
||||
content: [
|
||||
sanitizeToolContent(id, state.content[0]),
|
||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
||||
],
|
||||
metadata: meta,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
input: { redacted: `tool-input:${id}` },
|
||||
content: state.content
|
||||
? [
|
||||
sanitizeToolContent(id, state.content[0]),
|
||||
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
|
||||
]
|
||||
: undefined,
|
||||
metadata: meta,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
|
||||
if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
|
||||
return {
|
||||
...content,
|
||||
uri: redact("tool-file-uri", id, content.uri),
|
||||
name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
|
||||
}
|
||||
}
|
||||
+351
-269
@@ -6,12 +6,14 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
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 { Config } from "./config"
|
||||
import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { WorkspaceEnvironment } from "./workspace/environment"
|
||||
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
@@ -19,6 +21,18 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("She
|
||||
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.
|
||||
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
|
||||
const EXITED_LIMIT = 25
|
||||
@@ -50,7 +64,7 @@ export interface Interface {
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E, R>
|
||||
) => Effect.Effect<Shell.Info, E | InvalidCwdError, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||
@@ -65,286 +79,354 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
|
||||
|
||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */
|
||||
interface Backend {
|
||||
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
|
||||
/** Shell executable; re-evaluated per command so config changes apply. */
|
||||
readonly shell: Effect.Effect<string>
|
||||
readonly args: (shell: string, command: string) => readonly string[]
|
||||
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
|
||||
readonly env: Readonly<Record<string, string | undefined>>
|
||||
readonly detached: boolean
|
||||
readonly validateCwd: (cwd: string) => Effect.Effect<void, InvalidCwdError>
|
||||
}
|
||||
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
const layerWith = <E, R>(backend: Effect.Effect<Backend, E, R>) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
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[] = []
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
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) }))
|
||||
const outputDir = path.join(global.data, "shell", location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
|
||||
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
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" })),
|
||||
),
|
||||
)
|
||||
|
||||
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 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))
|
||||
}),
|
||||
)
|
||||
export const layer = (options?: ShellSelect.Options) =>
|
||||
layerWith(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
|
||||
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* 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 })
|
||||
}),
|
||||
)
|
||||
spawn: (command) => appProcess.spawn(command),
|
||||
shell: config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
|
||||
args: ShellSelect.args,
|
||||
env: process.env,
|
||||
detached: process.platform !== "win32",
|
||||
validateCwd: validateCwdWith(
|
||||
(cwd) => fs.stat(cwd),
|
||||
(error) => error.reason._tag === "NotFound",
|
||||
),
|
||||
} satisfies Backend
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, FSUtil.node, PluginHooks.node],
|
||||
})
|
||||
}
|
||||
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -12,8 +12,6 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
@@ -110,130 +108,113 @@ export const Plugin = {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
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 external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
|
||||
const info = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
),
|
||||
)
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing =
|
||||
exact.length > 0 || unicode.length > 0
|
||||
? []
|
||||
: findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
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 source = yield* files.read(target).pipe(
|
||||
Effect.catchTag("FileMutation.NotFoundError", () =>
|
||||
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}` })),
|
||||
),
|
||||
)
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const exact = findOccurrences(source, oldString)
|
||||
// These one-to-one mappings preserve offsets into the original source.
|
||||
const unicode =
|
||||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
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 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)
|
||||
}),
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
@@ -42,104 +40,83 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const location = yield* Location.Service
|
||||
const resolve = Location.paths(location).resolve
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
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,
|
||||
})
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
limit: input.limit,
|
||||
},
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const info = yield* fs
|
||||
.stat(target.canonical)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
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 },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
|
||||
)
|
||||
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: 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)
|
||||
}),
|
||||
|
||||
@@ -5,21 +5,19 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { FileSystem } from "../../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { Ripgrep } from "../../ripgrep"
|
||||
import { RelativePath } from "../../schema"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
pattern: FileSystem.GrepInput.fields.pattern.check(
|
||||
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
|
||||
).annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
pattern: FileSystem.GrepInput.fields.pattern
|
||||
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
|
||||
.annotate({
|
||||
description: "Regular expression to search for in file contents (ripgrep syntax)",
|
||||
}),
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "File or directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
@@ -58,112 +56,82 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const location = yield* Location.Service
|
||||
const resolve = Location.paths(location).resolve
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
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,
|
||||
})
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const root = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs
|
||||
.stat(root)
|
||||
.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
)
|
||||
const cwd = info?.type === "Directory" ? root : path.dirname(root)
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* ripgrep
|
||||
.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
file: info?.type === "File" ? path.basename(root) : undefined,
|
||||
include: input.include,
|
||||
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((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,
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: ".",
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
|
||||
const matches = yield* filesystem
|
||||
.grep({
|
||||
target,
|
||||
pattern: input.pattern,
|
||||
include: input.include,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
FileSystem.searchTimeout((message) => new ToolFailure({ message })),
|
||||
Effect.catchTag("FileSystem.SearchPathError", () =>
|
||||
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
|
||||
),
|
||||
metadata: { matches: result.matches.length, truncated: result.truncated },
|
||||
})),
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: error instanceof Ripgrep.InvalidPatternError
|
||||
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
|
||||
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
|
||||
)
|
||||
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: 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 }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -6,11 +6,8 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { Location } from "../../location"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -57,286 +54,232 @@ type Prepared =
|
||||
readonly moveTarget?: Target
|
||||
})
|
||||
|
||||
interface Target {
|
||||
readonly canonical: string
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: {
|
||||
readonly directory: string
|
||||
readonly resource: string
|
||||
}
|
||||
}
|
||||
type Target = LocationMutation.Target
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
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(
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (operation: string, error: unknown) => {
|
||||
const completed = applied.map((item) => item.resource).join(", ")
|
||||
return new ToolFailure({
|
||||
message: `${operation}: ${errorMessage(error)}${completed ? `. Completed before failure: ${completed}` : ""}`,
|
||||
})
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
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(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
|
||||
)
|
||||
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(
|
||||
(error) => new ToolFailure({ message: `patch verification failed: ${error.message}` }),
|
||||
(error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
),
|
||||
)
|
||||
if (hunks.length === 0) {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
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,
|
||||
})
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const targets: Target[] = []
|
||||
const updates = new Map<string, string>()
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = resolveTarget(location, hunk.path)
|
||||
targets.push(target)
|
||||
if (target.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: [target.externalDirectory.resource],
|
||||
save: [target.externalDirectory.resource],
|
||||
metadata: {
|
||||
filepath: target.canonical,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
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 }),
|
||||
if (hunk.type === "add") {
|
||||
const contents =
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
prepared.push({
|
||||
...hunk,
|
||||
contents,
|
||||
target,
|
||||
before: "",
|
||||
after: FileMutation.normalizeText(contents),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* files.read(target).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to delete ${target.resource}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content, after: "" })
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
const previous = updates.get(target.canonical)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* files.read(target).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
))
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) => new ToolFailure({ message: `patch verification failed: ${errorMessage(error)}` }),
|
||||
})
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
yield* fs
|
||||
.writeWithDirs(
|
||||
change.target.canonical,
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
)
|
||||
.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,
|
||||
})
|
||||
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)))
|
||||
const content = FileMutation.normalizeText(update.content)
|
||||
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined
|
||||
if (moveTarget) targets.push(moveTarget)
|
||||
if (moveTarget?.externalDirectory) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
before: original,
|
||||
after: content,
|
||||
moveTarget,
|
||||
})
|
||||
return { applied, files }
|
||||
if (!moveTarget) updates.set(target.canonical, content)
|
||||
}).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 }),
|
||||
: new ToolFailure({ message: `Unable to prepare patch at ${hunk.path}`, 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)
|
||||
|
||||
@@ -354,8 +297,13 @@ export const Plugin = {
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
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.reason._tag === "NotFound") return "file does not exist"
|
||||
return error.reason.description ?? error.reason.message
|
||||
@@ -365,9 +313,7 @@ function errorMessage(error: unknown) {
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
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 =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
@@ -414,24 +360,3 @@ function trimDiff(diff: string) {
|
||||
})
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,7 @@ const LocationInput = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
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 = {
|
||||
id: "opencode.tool.read",
|
||||
@@ -43,99 +39,99 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
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)
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const type = yield* reader.inspect(absolute).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)),
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(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,
|
||||
)
|
||||
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* () {
|
||||
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
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).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 })
|
||||
}),
|
||||
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(
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Array, Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { PluginRuntime } from "../../plugin/runtime"
|
||||
@@ -82,7 +81,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -154,10 +152,12 @@ export const Plugin = {
|
||||
)
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
const external = Array.dedupeWith(
|
||||
[target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined),
|
||||
(left, right) => left.resource === right.resource,
|
||||
)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
@@ -176,13 +176,6 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
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 })
|
||||
|
||||
@@ -2,14 +2,15 @@ export * as WebSearchTool from "./websearch"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { Form } from "../../form"
|
||||
import { KV } from "../../kv"
|
||||
import { Permission } from "../../permission"
|
||||
import { WebSearch } from "../../websearch"
|
||||
import { WebSearchPreference } from "../../websearch-preference"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
const providerSelectionLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
@@ -28,7 +29,8 @@ export const Plugin = {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const preference = yield* WebSearchPreference.Service
|
||||
const kv = yield* KV.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -49,82 +51,90 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const result = yield* ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return preference
|
||||
.synchronized(
|
||||
Effect.gen(function* () {
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const stored = yield* preference.get()
|
||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
||||
if (typeof stored === "string" && providers.some((provider) => provider.id === stored))
|
||||
return yield* Effect.void
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* preference.set(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
ctx.websearch.query(input).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return yield* Effect.void
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
value: "allow",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (typeof providerID !== "string" || !providers.some((provider) => provider.id === providerID))
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
return yield* preference.set(providerID)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.andThen(ctx.websearch.query(input)))
|
||||
}),
|
||||
)
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* kv.set("websearch:provider", false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
!providers.some((provider) => provider.id === providerID)
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
return yield* kv.set("websearch:provider", providerID)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.andThen(Effect.suspend(search)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = yield* search()
|
||||
const output = {
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
@@ -152,7 +162,7 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
if ((yield* preference.get()) === false) delete event.tools[name]
|
||||
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -9,10 +9,7 @@ export * as WriteTool from "./write"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
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 { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
import { fileDiff } from "./file-diff"
|
||||
@@ -48,65 +45,64 @@ export const Plugin = {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add(
|
||||
({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current?.text ?? "",
|
||||
next.text,
|
||||
current ? "modified" : "added",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
draft.add({
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"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.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* files
|
||||
.read(target)
|
||||
.pipe(Effect.catchTag("FileMutation.NotFoundError", () => Effect.succeed(undefined)))
|
||||
const content = FileMutation.normalizeText(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current ?? "",
|
||||
content,
|
||||
current === undefined ? "added" : "modified",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const result = yield* files.write({ target, content: input.content })
|
||||
return {
|
||||
operation: result.operation,
|
||||
target: result.target,
|
||||
resource: result.resource,
|
||||
existed: result.existed,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -2,11 +2,13 @@ export * as ReadToolFileSystem from "./read-filesystem"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Option, Schema, Scope } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
import { WorkspaceEnvironment } from "../workspace/environment"
|
||||
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
@@ -109,6 +111,24 @@ export interface Interface {
|
||||
|
||||
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([
|
||||
".zip",
|
||||
".tar",
|
||||
@@ -169,7 +189,10 @@ const decodeUtf8 = (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)
|
||||
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
|
||||
export const inspect = Effect.fn("ReadTool.inspect")(function* (
|
||||
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
|
||||
input: string,
|
||||
) {
|
||||
const info = yield* fs.stat(input)
|
||||
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" }))
|
||||
@@ -177,7 +200,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Inter
|
||||
})
|
||||
|
||||
export const read = Effect.fn("ReadTool.read")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
fs: ReadSource,
|
||||
input: string,
|
||||
resource: string,
|
||||
page: PageInput = {},
|
||||
@@ -210,10 +233,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
type: "file" as const,
|
||||
uri: pathToFileURL(real).href,
|
||||
name: path.basename(real),
|
||||
content: Buffer.concat(
|
||||
chunks.map((chunk) => Buffer.from(chunk)),
|
||||
total,
|
||||
).toString("base64"),
|
||||
content: Buffer.concat(chunks, total).toString("base64"),
|
||||
encoding: "base64" as const,
|
||||
mime,
|
||||
}
|
||||
@@ -334,8 +354,6 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
|
||||
const real = yield* fs.realPath(input)
|
||||
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(
|
||||
items,
|
||||
(item) =>
|
||||
@@ -353,9 +371,16 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const visible = entries
|
||||
.filter((item): item is FileSystem.Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
|
||||
return sortAndPage(
|
||||
entries.filter((item): item is FileSystem.Entry => item !== undefined),
|
||||
page,
|
||||
)
|
||||
})
|
||||
|
||||
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 truncated = offset - 1 + selected.length < visible.length
|
||||
return new ListPage({
|
||||
@@ -364,7 +389,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
|
||||
truncated,
|
||||
...(truncated ? { next: offset + selected.length } : {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -379,3 +404,118 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigV1 from "./config"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
|
||||
import { ConfigReference } from "../../config/reference"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigAttachmentV1 } from "./attachment"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * as ConfigMigrateV1 from "./migrate"
|
||||
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigV1 } from "./config"
|
||||
import { ConfigAgentV1 } from "./agent"
|
||||
import { ConfigCommandV1 } from "./command"
|
||||
@@ -10,80 +13,52 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
|
||||
import { Provider } from "../../provider"
|
||||
import { Model } from "../../model"
|
||||
|
||||
const keys = new Set([
|
||||
"logLevel",
|
||||
"server",
|
||||
"command",
|
||||
"reference",
|
||||
"snapshot",
|
||||
"plugin",
|
||||
"autoshare",
|
||||
"disabled_providers",
|
||||
"enabled_providers",
|
||||
"small_model",
|
||||
"mode",
|
||||
"agent",
|
||||
"provider",
|
||||
"permission",
|
||||
"tools",
|
||||
"attachment",
|
||||
"layout",
|
||||
])
|
||||
|
||||
export function isV1(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
|
||||
const record = input as Record<string, unknown>
|
||||
if (Object.keys(record).some((key) => keys.has(key))) return true
|
||||
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
|
||||
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
|
||||
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
|
||||
const mcp = record.mcp
|
||||
return (
|
||||
typeof mcp === "object" &&
|
||||
mcp !== null &&
|
||||
!Array.isArray(mcp) &&
|
||||
!("servers" in mcp) &&
|
||||
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
|
||||
)
|
||||
}
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
|
||||
const encodeInfo = Schema.encodeSync(Info)
|
||||
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
|
||||
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
|
||||
export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
return {
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
return encodeInfo(
|
||||
decodeInfo(
|
||||
JSON.stringify({
|
||||
$schema: info.$schema,
|
||||
shell: info.shell,
|
||||
model: modelSelection(info.model),
|
||||
default_agent: info.default_agent,
|
||||
autoupdate: info.autoupdate,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
permissions: permissions(info.permission, info.tools),
|
||||
agents: agents(info),
|
||||
snapshots: info.snapshot,
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
auto: info.compaction.auto,
|
||||
prune: info.compaction.prune,
|
||||
keep: {
|
||||
tokens: info.compaction.preserve_recent_tokens,
|
||||
},
|
||||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
experimental: experimental(info),
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}),
|
||||
),
|
||||
providers: providers(info.provider),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function experimental(info: typeof ConfigV1.Info.Type) {
|
||||
@@ -132,7 +107,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
|
||||
}
|
||||
|
||||
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
|
||||
function normalizeAction(action: string) {
|
||||
export function normalizeAction(action: string) {
|
||||
if (action === "write" || action === "patch") return "edit"
|
||||
if (action === "task") return "subagent"
|
||||
if (action === "bash") return "shell"
|
||||
@@ -154,21 +129,25 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
|
||||
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
|
||||
}
|
||||
return {
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}
|
||||
return encodeAgent(
|
||||
decodeAgent(
|
||||
JSON.stringify({
|
||||
model: modelSelection(info.model, info.variant),
|
||||
request: Object.keys(body).length ? { body } : undefined,
|
||||
system: info.prompt,
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([id, command]) => [
|
||||
@@ -205,7 +184,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
|
||||
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
|
||||
}
|
||||
|
||||
function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
export function migrateMcp(info: ConfigMCPV1.Info) {
|
||||
const disabled = info.enabled === undefined ? undefined : !info.enabled
|
||||
if (info.type === "local")
|
||||
return {
|
||||
@@ -244,7 +223,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
|
||||
)
|
||||
}
|
||||
|
||||
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
|
||||
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
|
||||
return migrateStandardProvider(info)
|
||||
@@ -256,7 +235,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
models:
|
||||
@@ -300,8 +279,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename these only in files detected as V1 by a field that exists only in the old config format.
|
||||
function providerID(input: string) {
|
||||
// Rename these only while migrating unambiguous V1 fields.
|
||||
export function providerID(input: string) {
|
||||
if (input === "azure-cognitive-services") return "azure"
|
||||
if (input === "google-vertex-anthropic") return "google-vertex"
|
||||
return input
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export * as WebSearchPreference from "./websearch-preference"
|
||||
|
||||
import { Context, Effect, Layer, Semaphore } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { KV } from "./kv"
|
||||
|
||||
const key = "websearch:provider"
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<KV.Value | undefined>
|
||||
readonly set: (value: KV.Value) => Effect.Effect<void>
|
||||
readonly synchronized: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearchPreference") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
return Service.of({
|
||||
get: () => kv.get(key),
|
||||
set: (value) => kv.set(key, value),
|
||||
synchronized: (effect) => lock.withPermit(effect),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
|
||||
@@ -4,8 +4,8 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { KV } from "./kv"
|
||||
import { State } from "./state"
|
||||
import { WebSearchPreference } from "./websearch-preference"
|
||||
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
@@ -75,7 +75,7 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const preference = yield* WebSearchPreference.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
@@ -98,7 +98,7 @@ const layer = Layer.effect(
|
||||
const data = state.get()
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* preference.get()
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
return data.providers.get(ID.make(stored))
|
||||
@@ -140,5 +140,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, WebSearchPreference.node],
|
||||
deps: [Bus.node, KV.node],
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user