Compare commits

..

7 Commits

Author SHA1 Message Date
Luke Parker a81ab9385b fix(desktop): constrain renderer navigation 2026-08-07 03:10:29 +00:00
Kit Langton cc7827fe08 refactor(core): simplify file tools to lexical paths (#40962) 2026-08-06 21:53:52 -04:00
opencode-agent[bot] 76e4d88d21 fix(core): default custom agents to primary (#40880)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:47:51 -05:00
opencode-agent[bot] 439ed66c7b fix(core): migrate legacy small model (#40966)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-06 20:46:12 -05:00
Kit Langton 047d434aa2 fix(tui): dismiss stale permission prompts (#40960) 2026-08-06 21:43:44 -04:00
opencode-agent[bot] d7651519f3 fix(tui): use tab layout setting (#40952)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-07 01:19:28 +00:00
Kit Langton 1eb3a43add fix(tui): keep model selection session scoped (#40913) 2026-08-06 20:34:57 -04:00
123 changed files with 2277 additions and 5316 deletions
-40
View File
@@ -678,10 +678,8 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0",
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -1513,18 +1511,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@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=="],
@@ -1733,10 +1719,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@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=="],
@@ -1831,8 +1813,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@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=="],
@@ -3317,8 +3297,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-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=="],
@@ -3559,10 +3537,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4595,8 +4569,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4823,8 +4795,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"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=="],
@@ -4863,10 +4833,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5665,8 +5631,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6625,8 +6589,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"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=="],
@@ -6771,8 +6733,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
@@ -26,6 +26,7 @@ export default Runtime.handler(
const location = await client.location.get({ location: { directory: process.cwd() } })
const page = await client.session.list({
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
order: "desc",
limit: 50,
+1 -1
View File
@@ -86,7 +86,7 @@ export async function runMini(input: MiniCommandInput) {
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory },
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
+2 -1
View File
@@ -703,7 +703,7 @@ export async function runNonInteractivePrompt(input: Input) {
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory },
location: { directory: input.location.directory, workspace: input.location.workspaceID },
})
.catch(() => undefined),
])
@@ -757,6 +757,7 @@ function formRequestOptions(location: LocationRef | undefined): [] | [{ headers:
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
+1 -1
View File
@@ -96,7 +96,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
next.model ??
(options.variant
? await client.model
.default({ location: { directory: next.location.directory } })
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data)
: undefined)
const model = selected
+1
View File
@@ -136,6 +136,7 @@ async function latestSession(
const page = await client.session.list(
{
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
limit: SESSION_PAGE_LIMIT,
order: "desc",
+2 -1
View File
@@ -426,13 +426,14 @@ describe("runNonInteractivePrompt", () => {
const globalOptions = {
headers: {
"x-opencode-directory": "%2Fwork%20tree",
"x-opencode-workspace": "wrk_1",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
location: { directory: "/work tree", workspace: "wrk_1" },
})
expect(sdk.question.list).not.toHaveBeenCalled()
expect(sdk.question.reject).not.toHaveBeenCalled()
+107 -60
View File
@@ -3,13 +3,13 @@ import type { Effect, Stream } from "effect"
import type { Location } from "@opencode-ai/schema/location"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Plugin } from "@opencode-ai/schema/plugin"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { Session } from "@opencode-ai/schema/session"
import type { AbsolutePath } from "@opencode-ai/schema/schema"
import type { Project } from "@opencode-ai/schema/project"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
@@ -59,7 +59,9 @@ export interface ServerApi<E = never> {
readonly get: ServerGetOperation<E>
}
export type Endpoint2_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint2_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint2_0Output = Location.Info
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
@@ -67,13 +69,15 @@ export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
}
export type Endpoint3_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint3_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Agent.Info> }
export type AgentListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
export type Endpoint3_1Input = {
readonly agentID: Agent.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint3_1Output = { readonly location: Location.Info; readonly data: Agent.Info }
export type AgentGetOperation<E = never> = (input: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
@@ -83,7 +87,9 @@ export interface AgentApi<E = never> {
readonly get: AgentGetOperation<E>
}
export type Endpoint4_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint4_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint4_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
@@ -92,6 +98,7 @@ export interface PluginApi<E = never> {
}
export type Endpoint5_0Input = {
readonly workspace?: Workspace.ID | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -926,11 +933,15 @@ export interface MessageApi<E = never> {
readonly list: MessageListOperation<E>
}
export type Endpoint7_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint7_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Model.Info> }
export type ModelListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
export type Endpoint7_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint7_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint7_1Output = { readonly location: Location.Info; readonly data: Model.Info | undefined }
export type ModelDefaultOperation<E = never> = (input?: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
@@ -940,7 +951,7 @@ export interface ModelApi<E = never> {
}
export type Endpoint8_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string
readonly model?: Model.Ref | undefined
}
@@ -951,13 +962,15 @@ export interface GenerateApi<E = never> {
readonly text: GenerateTextOperation<E>
}
export type Endpoint9_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint9_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Provider.Info> }
export type ProviderListOperation<E = never> = (input?: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
export type Endpoint9_1Input = {
readonly providerID: Provider.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint9_1Output = { readonly location: Location.Info; readonly data: Provider.Info }
export type ProviderGetOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
@@ -967,19 +980,21 @@ export interface ProviderApi<E = never> {
readonly get: ProviderGetOperation<E>
}
export type Endpoint10_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint10_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Integration.Info> }
export type IntegrationListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
export type Endpoint10_1Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_1Output = { readonly location: Location.Info; readonly data: Integration.Info | undefined }
export type IntegrationGetOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export type Endpoint10_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly url: string
}
export type Endpoint10_2Output = void
@@ -989,7 +1004,7 @@ export type IntegrationWellknownAddOperation<E = never> = (
export type Endpoint10_3Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly key: string
readonly label?: string | undefined
}
@@ -1000,7 +1015,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
export type Endpoint10_4Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID
readonly inputs: { readonly [x: string]: string }
readonly label?: string | undefined
@@ -1013,7 +1028,7 @@ export type IntegrationOauthConnectOperation<E = never> = (
export type Endpoint10_5Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_5Output = { readonly location: Location.Info; readonly data: Integration.AttemptStatus }
export type IntegrationOauthStatusOperation<E = never> = (
@@ -1023,7 +1038,7 @@ export type IntegrationOauthStatusOperation<E = never> = (
export type Endpoint10_6Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly code?: string | undefined
}
export type Endpoint10_6Output = void
@@ -1034,7 +1049,7 @@ export type IntegrationOauthCompleteOperation<E = never> = (
export type Endpoint10_7Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_7Output = void
export type IntegrationOauthCancelOperation<E = never> = (
@@ -1043,7 +1058,7 @@ export type IntegrationOauthCancelOperation<E = never> = (
export type Endpoint10_8Input = {
readonly integrationID: Integration.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly methodID: Integration.MethodID
readonly label?: string | undefined
}
@@ -1055,7 +1070,7 @@ export type IntegrationCommandConnectOperation<E = never> = (
export type Endpoint10_9Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_9Output = { readonly location: Location.Info; readonly data: Integration.CommandAttemptStatus }
export type IntegrationCommandStatusOperation<E = never> = (
@@ -1065,7 +1080,7 @@ export type IntegrationCommandStatusOperation<E = never> = (
export type Endpoint10_10Input = {
readonly integrationID: Integration.ID
readonly attemptID: Integration.AttemptID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint10_10Output = void
export type IntegrationCommandCancelOperation<E = never> = (
@@ -1090,13 +1105,15 @@ export interface IntegrationApi<E = never> {
}
}
export type Endpoint11_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint11_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Mcp.Server> }
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
export type Endpoint11_1Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly config: Mcp.LocalConfig | Mcp.RemoteConfig
}
export type Endpoint11_1Output = void
@@ -1104,26 +1121,28 @@ export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Ef
export type Endpoint11_2Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_2Output = void
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
export type Endpoint11_3Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_3Output = void
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export type Endpoint11_4Input = {
readonly server: string
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_4Output = void
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
export type Endpoint11_5Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint11_5Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint11_5Output = { readonly location: Location.Info; readonly data: Mcp.ResourceCatalog }
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
@@ -1138,7 +1157,7 @@ export interface McpApi<E = never> {
export type Endpoint12_0Input = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly label: string
}
export type Endpoint12_0Output = void
@@ -1146,7 +1165,7 @@ export type CredentialUpdateOperation<E = never> = (input: Endpoint12_0Input) =>
export type Endpoint12_1Input = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint12_1Output = void
export type CredentialRemoveOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
@@ -1159,13 +1178,15 @@ export interface CredentialApi<E = never> {
export type Endpoint13_0Output = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<Endpoint13_0Output, E>
export type Endpoint13_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint13_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
@@ -1176,7 +1197,9 @@ export interface ProjectApi<E = never> {
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint14_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint14_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
@@ -1220,7 +1243,9 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E>
}
export type Endpoint15_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint15_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint15_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Permission.Request> }
export type PermissionRequestListOperation<E = never> = (
input?: Endpoint15_0Input,
@@ -1278,14 +1303,14 @@ export interface PermissionApi<E = never> {
}
export type Endpoint16_0Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: RelativePath | undefined
}
export type Endpoint16_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
export type FileListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
export type Endpoint16_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
@@ -1298,7 +1323,9 @@ export interface FileApi<E = never> {
readonly find: FileFindOperation<E>
}
export type Endpoint17_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint17_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint17_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Command.Info> }
export type CommandListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
@@ -1306,7 +1333,9 @@ export interface CommandApi<E = never> {
readonly list: CommandListOperation<E>
}
export type Endpoint18_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint18_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint18_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Skill.Info> }
export type SkillListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
@@ -1321,12 +1350,14 @@ export interface EventApi<E = never> {
readonly subscribe: EventSubscribeOperation<E>
}
export type Endpoint20_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint20_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Pty.Info> }
export type PtyListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
export type Endpoint20_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command?: string | undefined
readonly args?: ReadonlyArray<string> | undefined
readonly cwd?: string | undefined
@@ -1338,14 +1369,14 @@ export type PtyCreateOperation<E = never> = (input?: Endpoint20_1Input) => Effec
export type Endpoint20_2Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_2Output = { readonly location: Location.Info; readonly data: Pty.Info }
export type PtyGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
export type Endpoint20_3Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly title?: string | undefined
readonly size?: { readonly rows: number; readonly cols: number } | undefined
}
@@ -1354,7 +1385,7 @@ export type PtyUpdateOperation<E = never> = (input: Endpoint20_3Input) => Effect
export type Endpoint20_4Input = {
readonly ptyID: Pty.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint20_4Output = void
export type PtyRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
@@ -1367,12 +1398,14 @@ export interface PtyApi<E = never> {
readonly remove: PtyRemoveOperation<E>
}
export type Endpoint21_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint21_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Shell.Info> }
export type ShellListOperation<E = never> = (input?: Endpoint21_0Input) => Effect.Effect<Endpoint21_0Output, E>
export type Endpoint21_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
@@ -1383,14 +1416,14 @@ export type ShellCreateOperation<E = never> = (input: Endpoint21_1Input) => Effe
export type Endpoint21_2Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_2Output = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: Endpoint21_2Input) => Effect.Effect<Endpoint21_2Output, E>
export type Endpoint21_3Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly timeout: number
}
export type Endpoint21_3Output = { readonly location: Location.Info; readonly data: Shell.Info }
@@ -1398,7 +1431,7 @@ export type ShellTimeoutOperation<E = never> = (input: Endpoint21_3Input) => Eff
export type Endpoint21_4Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}
@@ -1415,7 +1448,7 @@ export type ShellOutputOperation<E = never> = (input: Endpoint21_4Input) => Effe
export type Endpoint21_5Input = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint21_5Output = void
export type ShellRemoveOperation<E = never> = (input: Endpoint21_5Input) => Effect.Effect<Endpoint21_5Output, E>
@@ -1429,7 +1462,9 @@ export interface ShellApi<E = never> {
readonly remove: ShellRemoveOperation<E>
}
export type Endpoint22_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint22_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
export type QuestionRequestListOperation<E = never> = (
input?: Endpoint22_0Input,
@@ -1458,7 +1493,9 @@ export interface QuestionApi<E = never> {
readonly reject: QuestionRejectOperation<E>
}
export type Endpoint23_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint23_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
@@ -1468,7 +1505,7 @@ export interface ReferenceApi<E = never> {
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
@@ -1478,7 +1515,7 @@ export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) =
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
@@ -1487,7 +1524,7 @@ export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) =
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_2Output = void
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
@@ -1498,16 +1535,20 @@ export interface ProjectCopyApi<E = never> {
readonly refresh: ProjectCopyRefreshOperation<E>
}
export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint25_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint25_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type Endpoint25_2Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
readonly context?: number | undefined
}
@@ -1523,7 +1564,9 @@ export interface VcsApi<E = never> {
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
export type Endpoint26_1Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint26_1Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint26_1Output = void
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
@@ -1548,12 +1591,14 @@ export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint28_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
export type Endpoint28_1Input = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly providerID?: WebSearch.ID | undefined
}
@@ -1565,7 +1610,9 @@ export interface WebsearchApi<E = never> {
readonly query: WebsearchQueryOperation<E>
}
export type Endpoint29_0Input = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type Endpoint29_0Input = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
@@ -292,6 +292,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
preserveEffect<Endpoint5_0Output>()(
raw["session.list"]({
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
@@ -448,6 +448,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session`,
query: {
workspace: input?.["workspace"],
limit: input?.["limit"],
order: input?.["order"],
search: input?.["search"],
@@ -476,7 +477,7 @@ export function make(options: ClientOptions) {
location: input?.["location"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
+199 -68
View File
@@ -2283,7 +2283,9 @@ export type HealthStopOutput = ServiceStopResponse
export type ServerGetOutput = { urls: Array<string> }
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type LocationGetOutput = {
@@ -2293,7 +2295,9 @@ export type LocationGetOutput = {
}
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentListOutput = {
@@ -2303,7 +2307,9 @@ export type AgentListOutput = {
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentGetOutput = {
@@ -2312,7 +2318,9 @@ export type AgentGetOutput = {
}
export type PluginListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PluginListOutput = {
@@ -2321,7 +2329,19 @@ export type PluginListOutput = {
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly parentID?: string | null | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2332,6 +2352,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2342,6 +2363,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2352,6 +2374,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["search"]
readonly parentID?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2362,6 +2385,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["parentID"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2372,6 +2396,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2382,6 +2407,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -2392,6 +2418,7 @@ export type SessionListInput = {
readonly cursor?: string | undefined
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
@@ -3733,7 +3760,9 @@ export type MessageListInput = {
export type MessageListOutput = SessionMessagesResponse
export type ModelListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelListOutput = {
@@ -3742,7 +3771,9 @@ export type ModelListOutput = {
}
export type ModelDefaultInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelDefaultOutput = {
@@ -3751,7 +3782,9 @@ export type ModelDefaultOutput = {
}
export type GenerateTextInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -3765,7 +3798,9 @@ export type GenerateTextInput = {
export type GenerateTextOutput = GenerateTextResponse["data"]
export type ProviderListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProviderListOutput = {
@@ -3775,7 +3810,9 @@ export type ProviderListOutput = {
export type ProviderGetInput = {
readonly providerID: { readonly providerID: string }["providerID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProviderGetOutput = {
@@ -3784,7 +3821,9 @@ export type ProviderGetOutput = {
}
export type IntegrationListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationListOutput = {
@@ -3794,7 +3833,9 @@ export type IntegrationListOutput = {
export type IntegrationGetInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationGetOutput = {
@@ -3803,7 +3844,9 @@ export type IntegrationGetOutput = {
}
export type IntegrationWellknownAddInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly url: { readonly url: string }["url"]
}
@@ -3811,7 +3854,9 @@ export type IntegrationWellknownAddOutput = void
export type IntegrationConnectKeyInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly key: { readonly key: string; readonly label?: string | undefined }["key"]
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
}
@@ -3820,7 +3865,9 @@ export type IntegrationConnectKeyOutput = void
export type IntegrationOauthConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: {
readonly methodID: string
readonly inputs: { readonly [x: string]: string }
@@ -3852,7 +3899,9 @@ export type IntegrationOauthConnectOutput = {
export type IntegrationOauthStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationOauthStatusOutput = {
@@ -3863,7 +3912,9 @@ export type IntegrationOauthStatusOutput = {
export type IntegrationOauthCompleteInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly code?: { readonly code?: string | undefined }["code"]
}
@@ -3872,14 +3923,18 @@ export type IntegrationOauthCompleteOutput = void
export type IntegrationOauthCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationOauthCancelOutput = void
export type IntegrationCommandConnectInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly methodID: { readonly methodID: string; readonly label?: string | undefined }["methodID"]
readonly label?: { readonly methodID: string; readonly label?: string | undefined }["label"]
}
@@ -3892,7 +3947,9 @@ export type IntegrationCommandConnectOutput = {
export type IntegrationCommandStatusInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationCommandStatusOutput = {
@@ -3903,13 +3960,17 @@ export type IntegrationCommandStatusOutput = {
export type IntegrationCommandCancelInput = {
readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"]
readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationCommandCancelOutput = void
export type McpListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpListOutput = {
@@ -3919,7 +3980,9 @@ export type McpListOutput = {
export type McpAddInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly config: {
readonly config:
| {
@@ -3955,27 +4018,35 @@ export type McpAddOutput = void
export type McpRemoveInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpRemoveOutput = void
export type McpConnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpConnectOutput = void
export type McpDisconnectInput = {
readonly server: { readonly server: string }["server"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpDisconnectOutput = void
export type McpResourceCatalogInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type McpResourceCatalogOutput = {
@@ -3985,7 +4056,9 @@ export type McpResourceCatalogOutput = {
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly label: { readonly label: string }["label"]
}
@@ -3993,7 +4066,9 @@ export type CredentialUpdateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialRemoveOutput = void
@@ -4001,20 +4076,26 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectCurrentInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type FormRequestListOutput = {
@@ -4868,7 +4949,9 @@ export type FormCancelInput = {
export type FormCancelOutput = void
export type PermissionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionRequestListOutput = {
@@ -4974,7 +5057,9 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type FileReadInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly path: string
}
@@ -4982,11 +5067,11 @@ export type FileReadOutput = globalThis.Uint8Array
export type FileListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
}["location"]
readonly path?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
}["path"]
}
@@ -4998,25 +5083,25 @@ export type FileListOutput = {
export type FileFindInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["location"]
readonly query: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["query"]
readonly type?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
}["type"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
readonly type?: "file" | "directory" | undefined
readonly limit?: number | undefined
@@ -5029,7 +5114,9 @@ export type FileFindOutput = {
}
export type CommandListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CommandListOutput = {
@@ -5038,7 +5125,9 @@ export type CommandListOutput = {
}
export type SkillListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type SkillListOutput = {
@@ -5049,7 +5138,9 @@ export type SkillListOutput = {
export type EventSubscribeOutput = V2Event
export type PtyListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyListOutput = {
@@ -5058,7 +5149,9 @@ export type PtyListOutput = {
}
export type PtyCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command?: {
readonly command?: string
readonly args?: ReadonlyArray<string>
@@ -5103,7 +5196,9 @@ export type PtyCreateOutput = {
export type PtyGetInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyGetOutput = {
@@ -5113,7 +5208,9 @@ export type PtyGetOutput = {
export type PtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly title?: {
readonly title?: string
readonly size?: { readonly rows: number; readonly cols: number }
@@ -5128,13 +5225,17 @@ export type PtyUpdateOutput = {
export type PtyRemoveInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtyRemoveOutput = void
export type ShellListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellListOutput = {
@@ -5143,7 +5244,9 @@ export type ShellListOutput = {
}
export type ShellCreateInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command: {
readonly command: string
readonly cwd?: string
@@ -5177,7 +5280,9 @@ export type ShellCreateOutput = {
export type ShellGetInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellGetOutput = {
@@ -5187,7 +5292,9 @@ export type ShellGetOutput = {
export type ShellTimeoutInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly timeout: { readonly timeout: number }["timeout"]
}
@@ -5199,17 +5306,17 @@ export type ShellTimeoutOutput = {
export type ShellOutputInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["location"]
readonly cursor?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["cursor"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["limit"]
@@ -5222,13 +5329,17 @@ export type ShellOutputOutput = {
export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellRemoveOutput = void
export type QuestionRequestListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionRequestListOutput = {
@@ -5256,7 +5367,9 @@ export type QuestionRejectInput = {
export type QuestionRejectOutput = void
export type ReferenceListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ReferenceListOutput = {
@@ -5266,7 +5379,9 @@ export type ReferenceListOutput = {
export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
@@ -5276,7 +5391,9 @@ export type ProjectCopyCreateOutput = ProjectCopyCopy
export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
@@ -5285,13 +5402,17 @@ export type ProjectCopyRemoveOutput = void
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsGetOutput = {
@@ -5300,7 +5421,9 @@ export type VcsGetOutput = {
}
export type VcsStatusInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type VcsStatusOutput = {
@@ -5310,17 +5433,17 @@ export type VcsStatusOutput = {
export type VcsDiffInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["location"]
readonly mode: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["mode"]
readonly context?: {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: "working" | "branch"
readonly context?: number | undefined
}["context"]
@@ -5334,7 +5457,9 @@ export type VcsDiffOutput = {
export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type DebugLocationEvictOutput = void
@@ -5345,7 +5470,9 @@ export type MigrationV1StatusOutput =
| { status: "error"; error: string }
export type WebsearchProvidersInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WebsearchProvidersOutput = {
@@ -5354,7 +5481,9 @@ export type WebsearchProvidersOutput = {
}
export type WebsearchQueryInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly query: { readonly query: string; readonly providerID?: string }["query"]
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
}
@@ -5365,7 +5494,9 @@ export type WebsearchQueryOutput = {
}
export type ConfigGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ConfigGetOutput = Array<ConfigEntry>
+2 -1
View File
@@ -1021,12 +1021,13 @@ describe("OpenAPI.fromSpec", () => {
await Effect.runPromise(
location
.execute({ location: { directory: "/tmp" } })
.execute({ location: { directory: "/tmp", workspace: "workspace-1" } })
.pipe(Effect.provide(client.layer)),
)
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
})
test("serializes supported simple and form parameter shapes", async () => {
+105 -70
View File
@@ -1,11 +1,15 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2415fa65-3def-4f9f-be85-eb35f4f94ddd",
"id": "2d214a71-3b0a-48c1-a667-741952c4e188",
"prevIds": [
"4d283c23-533f-4b87-9aa4-218f7fd36bca"
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
],
"ddl": [
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "account_state",
"entityType": "tables"
@@ -71,8 +75,84 @@
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "''",
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "branch",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extra",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_used",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
@@ -1305,63 +1385,18 @@
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "binding",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "root",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "workspace"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_workspace_project_id_project_id_fk",
"entityType": "fks",
"table": "workspace"
},
{
@@ -1529,6 +1564,15 @@
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
"id"
@@ -1646,15 +1690,6 @@
"table": "session_v2",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
{
+18 -1
View File
@@ -41,7 +41,7 @@ export type Result =
| { 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 unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
const unsupportedExperimental = [
"disable_paste_summary",
"batch_tool",
@@ -113,6 +113,23 @@ export function normalize(input: unknown): Result {
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
: undefined
const migratedSmallModel = legacySmallModel
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
: undefined
if (legacySmallModel && !migratedSmallModel)
diagnostics.push({
kind: "unsupported",
path: ["small_model"],
message: "omitted unsupported legacy model reference",
})
if (migratedSmallModel)
legacyAgents.title = {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
+6 -3
View File
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
? resource.slice(6)
: undefined
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
return resource
}
@@ -0,0 +1,20 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/sql"
import { Project } from "../project"
import { Workspace } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<Project.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})
-2
View File
@@ -42,7 +42,5 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/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[]
@@ -1,14 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150823_remove_workspace",
up(tx) {
return Effect.gen(function* () {
// Legacy control-plane workspace references would otherwise route these
// sessions to hosted graphs whose workspace rows no longer exist.
yield* tx.run(`UPDATE \`session_v2\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
yield* tx.run(`DROP TABLE \`workspace\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -1,20 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260806150824_add_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`provider\` text NOT NULL,
\`binding\` text NOT NULL,
\`root\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
+13 -10
View File
@@ -4,6 +4,19 @@ import type { DatabaseMigration } from "./migration"
export default {
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,
@@ -203,16 +216,6 @@ export default {
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
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(
+48 -208
View File
@@ -1,49 +1,23 @@
export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { 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
readonly content: string | Uint8Array
}
export interface MoveInput {
readonly from: Target
readonly to: Target
/** Text for the destination; may differ from the source content. */
export interface TextWriteInput {
readonly target: Target
readonly content: string
}
@@ -52,208 +26,74 @@ 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 `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>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, 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
* Serialize file changes by absolute 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 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 locks = KeyedMutex.makeUnsafe<string>()
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
locks.withLock(target.absolute)(Effect.uninterruptible(effect))
const writeResult = (target: Target, existed: boolean): WriteResult => ({
operation: "write",
target: target.absolute,
resource: target.resource,
existed,
})
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* fs.exists(input.target.absolute)
yield* fs.writeWithDirs(input.target.absolute, 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.absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ write, writeTextPreservingBom })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Formatter.node] })
// 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],
})
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.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.
+45 -244
View File
@@ -7,9 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
import { WorkspaceEnvironment } from "./workspace/environment"
import { Ripgrep } from "./ripgrep"
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
export const ReadInput = Schema.Struct({
@@ -34,24 +32,7 @@ export type ListInput = typeof ListInput.Type
export { FindInput }
export const DEFAULT_SEARCH_LIMIT = 100
/** 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 const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
@@ -66,251 +47,71 @@ 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 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)
}),
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)),
),
)
}),
})
return Service.of({ find: search.find, ...searches, ...entries })
}),
)
export const node = makeLocationNode({
service: Service,
layer: baseLayer,
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],
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
+1
View File
@@ -10,6 +10,7 @@ const prefixes = {
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
export function ascending(prefix: keyof typeof prefixes, given?: string) {
+27 -154
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { WorkspaceEnvironment } from "./workspace/environment"
export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
@@ -24,11 +23,6 @@ export const ResolveInput = Schema.Struct({
})
export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literals(["non_directory_ancestor", "outside_workspace"]),
}) {}
export interface ExternalDirectoryAuthorization {
readonly action: "external_directory"
/** Lexical directory used as the external approval boundary. */
@@ -45,15 +39,9 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
})
export interface Target {
/** Canonical existing path, or missing path below a canonical directory. */
readonly canonical: string
/**
* Lexical resolved path before symlink canonicalization. Reads and writes
* address the referent (canonical); entry operations like remove address
* the name itself.
*/
/** Absolute lexical path. */
readonly absolute: string
/** Permission resource: Location-relative for internal paths, lexical absolute for external paths. */
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
readonly resource: string
readonly externalDirectory?: ExternalDirectoryAuthorization
}
@@ -64,73 +52,12 @@ export interface Interface {
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
interface ResolvedPath {
readonly canonical: string
readonly type?:
| "File"
| "Directory"
| "SymbolicLink"
| "BlockDevice"
| "CharacterDevice"
| "FIFO"
| "Socket"
| "Unknown"
readonly directory: string
readonly lexicalDirectory: string
}
/**
* 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 slash = (value: string) => value.replaceAll("\\", "/")
const layer = Layer.effect(
Service,
@@ -138,43 +65,33 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
}
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) {
const absolute = path.resolve(location.directory, input.path)
// External access follows the requested path boundary. Symlinks reached through an
// internal path intentionally retain internal permission semantics after canonicalization.
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
const resolved = yield* resolvePath(absolute)
const external = !lexicallyInternal
const resource = external
? FSUtil.slash(absolute)
: FSUtil.slash(path.relative(location.directory, absolute) || ".")
const externalDirectory = resolved.lexicalDirectory
const externalResource = FSUtil.slash(path.join(externalDirectory, "*"))
if (FSUtil.contains(location.directory, absolute)) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
const type =
input.kind === "directory"
? "Directory"
: (yield* fs
.stat(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))))?.type
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
const externalResource = slash(path.join(externalDirectory, "*"))
return {
canonical: resolved.canonical,
absolute,
resource,
externalDirectory: external
? {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: FSUtil.slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
),
}
: undefined,
resource: slash(absolute),
externalDirectory: {
action: "external_directory",
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
),
},
} satisfies Target
})
@@ -187,47 +104,3 @@ export const node = makeLocationNode({
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
// Mirrors the local resolve walk over WorkspaceEnvironment.Files with posix
// rules. Hosted paths are never external: everything outside the Location is
// rejected instead of routed to external_directory approval, because the
// approval boundary vocabulary is host-relative.
const hostedLayer = Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
const location = yield* Location.Service
// Canonicalized even when the Location sits on the workspace root: the
// environment does not guarantee a canonical directory (local fakes root
// at symlinked temp paths).
const root = yield* env.files.realPath(location.directory).pipe(Effect.orDie)
const resolvePath = resolvePathWith({
paths: path.posix,
realPathOptional: (target) => WorkspaceEnvironment.optional(env.files.realPath(target)),
statOptional: (target) => WorkspaceEnvironment.optional(env.files.stat(target)),
})
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.posix.resolve(location.directory, input.path)
if (!FSUtil.containsPosix(location.directory, absolute))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
const resolved = yield* resolvePath(absolute)
if (!FSUtil.containsPosix(root, resolved.canonical))
return yield* new PathError({ path: absolute, reason: "outside_workspace" })
return {
canonical: resolved.canonical,
absolute,
resource: path.posix.relative(location.directory, absolute) || ".",
} satisfies Target
})
return Service.of({ resolve })
}),
)
export const hostedNode = makeLocationNode({
service: Service,
layer: hostedLayer.pipe(Layer.orDie),
deps: [WorkspaceEnvironment.node, Location.node],
})
+1 -28
View File
@@ -11,7 +11,6 @@ import { FileMutation } from "./file-mutation"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Ripgrep } from "./ripgrep"
import { Generate } from "./generate"
import { Form } from "./form"
import { Image } from "./image"
@@ -48,8 +47,6 @@ 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"
@@ -107,27 +104,6 @@ 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> {
@@ -142,10 +118,7 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const workspaceID = ref.workspaceID
const allReplacements = replacements.concat(
workspaceID ? hostedReplacements(ref, workspaceID) : [[Location.node, Location.boundNode(ref)]],
)
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
-32
View File
@@ -1,9 +1,6 @@
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"
@@ -17,12 +14,6 @@ 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) =>
@@ -46,26 +37,3 @@ export const boundNode = (ref: Ref) =>
layer: layer(ref),
deps: [Project.node],
})
/**
* Hosted Locations state their Project instead of discovering it: host git
* and filesystem walks must never run against a provider directory. The
* Workspace root comes from the already-connected environment, avoiding a
* second workspace row read per graph build.
*/
export const hostedBoundNode = (ref: Ref, workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
layer: Layer.effect(
Service,
Effect.gen(function* () {
const env = yield* WorkspaceEnvironment.Service
return Service.of({
directory: ref.directory,
workspaceID,
project: Project.hostedGlobal(env.directory),
})
}),
),
deps: [WorkspaceEnvironment.node],
})
+3
View File
@@ -32,6 +32,7 @@ import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission"
import { Reference } from "../reference"
import { WebSearch } from "../websearch"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
import { Shell } from "../shell"
import { Skill } from "../skill"
@@ -88,6 +89,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const skill = yield* Skill.Service
@@ -119,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(Skill.Service, skill),
+2 -3
View File
@@ -282,9 +282,7 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -340,6 +338,7 @@ export const node = makeLocationNode({
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
SessionInstructions.node,
Shell.node,
Skill.node,
-12
View File
@@ -44,18 +44,6 @@ export interface Resolved {
readonly vcs?: Vcs
}
/**
* The stated Project for hosted Locations: global identity until rediscovery
* inside the Workspace finds a repository. Canonical "/" matches the local
* non-VCS fallback on posix hosts, so persisting it never repoints the global
* row at a provider path.
*/
export const hostedGlobal = (root: string): Resolved => ({
id: ID.global,
directory: AbsolutePath.make(root),
canonical: AbsolutePath.make("/"),
})
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
+5 -1
View File
@@ -1,5 +1,6 @@
export * as PtyTicket from "./ticket"
import { Workspace } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect"
@@ -13,6 +14,7 @@ export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: Workspace.ID
}
export interface Interface {
@@ -23,7 +25,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return record.ptyID === input.ptyID && record.directory === input.directory
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
+175 -246
View File
@@ -2,13 +2,11 @@ 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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { 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
@@ -20,13 +18,6 @@ import { WorkspaceEnvironment } from "./workspace/environment"
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({
@@ -99,208 +90,184 @@ 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
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",
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,
})),
})
}),
),
),
@@ -308,42 +275,4 @@ const layer = Layer.effect(
}),
)
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],
})
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
+14 -28
View File
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { 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"
@@ -26,8 +27,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { ForkEmptyError, MessageDecodeError, NotFoundError, WorkspaceDirectoryError } from "./session/error"
import { Workspace } from "./workspace"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event"
@@ -58,9 +58,12 @@ import { fileURLToPath } from "url"
// - by project
// - by subpath
// - by workspace (home is special)
export { ListAnchor }
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
@@ -150,9 +153,7 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
readonly data: SessionSchema.Info[]
}>
readonly create: (
input: CreateInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | Workspace.NotFoundError | WorkspaceDirectoryError>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
@@ -198,6 +199,7 @@ export interface Interface {
readonly move: (input: {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
@@ -233,7 +235,7 @@ export interface Interface {
id?: Event.ID
sessionID: SessionSchema.ID
command: string
}) => Effect.Effect<void, NotFoundError | Shell.InvalidCwdError>
}) => Effect.Effect<void, NotFoundError>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -278,7 +280,6 @@ const layer = Layer.effect(
const db = database.db
const bus = yield* Bus.Service
const projects = yield* Project.Service
const workspaces = yield* Workspace.Service
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
@@ -290,17 +291,6 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
// Host Project discovery must not run against provider directories; hosted
// Sessions state the global Project until rediscovery inside the Workspace.
const hostedProject = Effect.fn("Session.hostedProject")(function* (
workspaceID: Workspace.ID,
directory: AbsolutePath,
) {
const workspace = yield* workspaces.get(workspaceID)
if (!FSUtil.containsPosix(workspace.root, directory))
return yield* new WorkspaceDirectoryError({ workspaceID, directory, root: workspace.root })
return Project.hostedGlobal(workspace.root)
})
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
@@ -338,9 +328,7 @@ const layer = Layer.effect(
const location = parent?.location ?? input.location
if (location === undefined)
return yield* Effect.die(new Error("Session.create requires either location or an existing parentID"))
const project = location.workspaceID
? yield* hostedProject(location.workspaceID, location.directory)
: yield* projects.resolve(location.directory)
const project = yield* projects.resolve(location.directory)
yield* persistProject(project)
const projected = yield* bus
.publish(
@@ -352,9 +340,7 @@ const layer = Layer.effect(
projectID: project.id,
parentID: input.parentID,
location,
subpath: RelativePath.make(
FSUtil.slash(Location.paths(location).relative(project.directory, location.directory)),
),
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
model: input.model
@@ -441,6 +427,7 @@ const layer = Layer.effect(
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
@@ -714,10 +701,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)) {
@@ -728,9 +715,9 @@ const layer = Layer.effect(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(FSUtil.slash(path.relative(project.directory, directory))),
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
{ location: current.location },
)
@@ -1026,7 +1013,6 @@ export const node = makeGlobalNode({
Database.node,
Bus.node,
Project.node,
Workspace.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
-14
View File
@@ -2,7 +2,6 @@ export * as SessionErrors from "./error"
import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error"
@@ -19,19 +18,6 @@ export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("S
}
}
export class WorkspaceDirectoryError extends Schema.TaggedErrorClass<WorkspaceDirectoryError>()(
"Session.WorkspaceDirectoryError",
{
workspaceID: WorkspaceID,
directory: Schema.String,
root: Schema.String,
},
) {
override get message() {
return `Directory ${this.directory} is outside workspace ${this.workspaceID} root ${this.root}`
}
}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+10 -3
View File
@@ -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 { Workspace } from "../workspace"
import { WorkspaceTable } from "../control-plane/workspace.sql"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
@@ -376,6 +376,13 @@ 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) =>
@@ -386,7 +393,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory,
path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: null,
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
+269 -351
View File
@@ -6,14 +6,12 @@ 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"
@@ -21,18 +19,6 @@ 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
@@ -64,7 +50,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 | InvalidCwdError, R>
) => Effect.Effect<Shell.Info, E, 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>
@@ -79,354 +65,286 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
/** The provider-facing seams; everything else in this service is host-owned bookkeeping. */
interface Backend {
readonly spawn: (command: ChildProcess.Command) => ReturnType<AppProcess.Interface["spawn"]>
/** Shell executable; re-evaluated per command so config changes apply. */
readonly shell: Effect.Effect<string>
readonly args: (shell: string, command: string) => readonly string[]
/** Base environment for spawned commands. Hosted backends must not leak host process.env. */
readonly env: Readonly<Record<string, string | undefined>>
readonly detached: boolean
readonly validateCwd: (cwd: string) => Effect.Effect<void, InvalidCwdError>
}
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[] = []
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[] = []
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 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,
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 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" })),
),
sessions.clear()
exitOrder.length = 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
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))
}),
)
return {
spawn: (command) => appProcess.spawn(command),
shell: config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options))),
args: ShellSelect.args,
env: process.env,
detached: process.platform !== "win32",
validateCwd: validateCwdWith(
(cwd) => fs.stat(cwd),
(error) => error.reason._tag === "NotFound",
),
} satisfies Backend
}),
)
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 })
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, FSUtil.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
})
}
export const node = configured()
// 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],
})
+27 -12
View File
@@ -12,6 +12,8 @@ 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"
@@ -108,6 +110,8 @@ 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
@@ -148,14 +152,18 @@ export const Plugin = {
})
}
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 info = yield* fs
.stat(target.absolute)
.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.absolute)
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)
@@ -172,10 +180,9 @@ export const Plugin = {
(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)
? fileDiff(target.resource, source, replaced)
: undefined
yield* permission.assert({
action: "edit",
@@ -196,9 +203,17 @@ export const Plugin = {
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 })
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.absolute))
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return {
files: [fileDiff(result.resource, source, result.content)],
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
+33 -13
View File
@@ -5,8 +5,10 @@ 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"
@@ -40,9 +42,9 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const resolve = Location.paths(location).resolve
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
@@ -80,22 +82,40 @@ export const Plugin = {
agent: context.agent,
source,
})
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${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* filesystem
const entries = yield* ripgrep
.glob({
target,
cwd: target.absolute,
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 ?? "."}`,
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))),
}),
),
),
@@ -105,7 +125,7 @@ export const Plugin = {
Effect.map((result) => ({
output: result.entries,
content: toModelContent(
result.entries.map((entry) => ({ ...entry, path: resolve(location.directory, entry.path) })),
result.entries.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
result.truncated,
),
metadata: { count: result.entries.length, truncated: result.truncated },
+99 -67
View File
@@ -5,19 +5,21 @@ 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.",
}),
@@ -56,82 +58,112 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const 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)
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,
})
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
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 ?? "."}` })),
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,
),
)
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}` })
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 }),
),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
}),
+132 -70
View File
@@ -6,8 +6,11 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import { FileMutation } from "../../file-mutation"
import { LocationMutation } from "../../location-mutation"
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 { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt"
@@ -54,13 +57,21 @@ type Prepared =
readonly moveTarget?: Target
})
type Target = LocationMutation.Target
interface Target {
readonly absolute: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -95,40 +106,37 @@ export const Plugin = {
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: ${errorMessage(error)}` }),
),
)
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = yield* resolveHunkTarget(hunk.path)
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
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),
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* files.read(target).pipe(
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -136,30 +144,52 @@ export const Plugin = {
}),
),
)
prepared.push({ ...hunk, target, before: content, after: "" })
prepared.push({ ...hunk, target, before: content.text, after: "" })
return
}
const previous = updates.get(target.canonical)
const previous = updates.get(target.absolute)
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)}`,
}),
),
))
(yield* Effect.gen(function* () {
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${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 content = FileMutation.normalizeText(update.content)
const moveTarget = hunk.movePath ? yield* resolveHunkTarget(hunk.movePath) : undefined
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget) targets.push(moveTarget)
if (moveTarget?.externalDirectory) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory),
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.absolute,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
@@ -169,11 +199,11 @@ export const Plugin = {
...hunk,
target,
content: Patch.joinBom(update.content, update.bom),
before: original,
after: content,
before,
after: update.content,
moveTarget,
})
if (!moveTarget) updates.set(target.canonical, content)
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
@@ -198,76 +228,92 @@ export const Plugin = {
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 })
yield* fs
.writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.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,
target: change.target.absolute,
})
return
}
if (change.type === "delete") {
yield* files
.remove(change.target)
yield* fs
.remove(change.target.absolute)
.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,
target: change.target.absolute,
})
return
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
const result = yield* files
.move({ from: change.target, to: moveTarget, content: change.content })
yield* fs
.writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* fs
.remove(change.target.absolute)
.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),
fail(`Wrote ${moveTarget.resource} but failed to remove ${change.target.resource}`, error),
),
)
formatted.set(moveTarget.canonical, result.content)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
target: change.moveTarget.absolute,
})
return
}
const result = yield* files
.write({ target: change.target, content: change.content })
yield* fs
.writeWithDirs(change.target.absolute, 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,
target: change.target.absolute,
})
}),
{ discard: true },
)
const fileDiffs = yield* Effect.forEach(prepared, (change) => {
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)))
return Effect.succeed(patchFile(change, formatted.get(target.absolute)))
})
return { applied, files: fileDiffs }
return { applied, files }
}).pipe(
Effect.map((output) => ({
output,
@@ -297,13 +343,8 @@ 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
@@ -313,7 +354,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.absolute, change.target.absolute, change.before, after))
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
@@ -360,3 +401,24 @@ function trimDiff(diff: string) {
})
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
const absolute =
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, absolute) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, absolute))
const directory = path.dirname(absolute)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
absolute,
resource: path.relative(location.project.directory, absolute).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}
+7 -10
View File
@@ -53,7 +53,7 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const target = yield* mutation.resolve({ path: input.path })
const external = target.externalDirectory
if (external)
yield* permission.assert({
@@ -63,7 +63,7 @@ export const Plugin = {
source,
})
const resource = target.resource
const absolute = AbsolutePath.make(target.canonical)
const absolute = AbsolutePath.make(target.absolute)
yield* permission.assert({
action: name,
resources: [resource],
@@ -74,7 +74,7 @@ export const Plugin = {
})
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.canonical)))
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
@@ -88,10 +88,8 @@ export const Plugin = {
// 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)
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.absolute)
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.
@@ -122,7 +120,6 @@ export const Plugin = {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.MalformedUtf8Error ||
error instanceof ReadToolFileSystem.OffsetOutOfRangeError ||
error instanceof ReadToolFileSystem.PathKindError
? error.message
@@ -135,9 +132,9 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const missing = Effect.fn("ReadTool.missing")(function* (input: string, canonical: string) {
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(canonical)).pipe(
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
Effect.map((entries) =>
entries
.filter((entry) => {
+157 -148
View File
@@ -4,7 +4,8 @@ import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Array, Deferred, Effect, Schema, Scope } from "effect"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -81,6 +82,7 @@ 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
@@ -120,169 +122,176 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
({
name,
options: { codemode: false },
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.canonical)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.canonical, directory), kind: "directory" }),
draft.add({
name,
options: { codemode: false },
description: description(),
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.messageID,
id: context.id,
}
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
let finalTimeout = timeout
const info = yield* shell.create(
{
command: input.command,
cwd: input.workdir,
timeout,
metadata: { sessionID: context.sessionID },
},
(invocation) =>
Effect.gen(function* () {
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
)
invocation.cwd = target.absolute
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,
)
invocation.cwd = target.canonical
finalTimeout = invocation.timeout
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",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (external.length > 0)
yield* permission.assert({
action: "external_directory",
resources: external.map((item) => item.resource),
save: external.map((item) => item.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
if (parsed.commands.length > 0)
yield* permission.assert({
action: name,
resources: parsed.commands.map((command) => command.resource),
save: parsed.commands.map((command) => command.save),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}),
)
yield* context.progress({ shellID: info.id })
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
})
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const truncated = latest.size > MAX_CAPTURE_BYTES
const page = yield* shell.output(info.id, {
cursor: Math.max(0, latest.size - MAX_CAPTURE_BYTES),
limit: MAX_CAPTURE_BYTES,
})
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return {
output: `${page.output || "(no output)"}${notice}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
}
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: capture.truncated,
timeout: true,
status: "completed" as const,
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
return {
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
status: "completed" as const,
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
})
return yield* Deferred.await(settled)
}).pipe(
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
const result = yield* runtime.job
.block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
}
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return yield* Deferred.await(settled)
}).pipe(
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
}),
),
),
}),
)
.pipe(Effect.orDie)
+13 -16
View File
@@ -9,7 +9,10 @@ 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"
@@ -45,6 +48,8 @@ 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
@@ -72,16 +77,11 @@ export const Plugin = {
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",
const current = yield* Bom.readFile(fs, target.absolute).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],
@@ -91,13 +91,10 @@ export const Plugin = {
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,
}
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+47 -235
View File
@@ -2,13 +2,11 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema, Scope } from "effect"
import { systemError } from "effect/PlatformError"
import { Context, Effect, Layer, Option, Schema } from "effect"
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
@@ -36,14 +34,6 @@ export class MediaIngestLimitError extends Schema.TaggedErrorClass<MediaIngestLi
}
}
export class MalformedUtf8Error extends Schema.TaggedErrorClass<MalformedUtf8Error>()("ReadTool.MalformedUtf8Error", {
resource: Schema.String,
}) {
override get message() {
return `File is not valid UTF-8: ${this.resource}`
}
}
export class OffsetOutOfRangeError extends Schema.TaggedErrorClass<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number },
@@ -63,13 +53,7 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
}
export type InspectError = FSUtil.Error | PathKindError
export type ReadError =
| FSUtil.Error
| BinaryFileError
| MediaIngestLimitError
| MalformedUtf8Error
| OffsetOutOfRangeError
| PathKindError
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
@@ -92,9 +76,15 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
next: Schema.optionalKey(PositiveInt),
}) {}
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
export const ListEntry = Schema.Struct({
path: RelativePath,
type: Schema.Literals(["file", "directory", "symlink"]),
}).annotate({ identifier: "ReadTool.ListEntry" })
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
type: Schema.Literal("list-page"),
entries: Schema.Array(FileSystem.Entry),
entries: Schema.Array(ListEntry),
truncated: Schema.Boolean,
next: Schema.optionalKey(PositiveInt),
}) {}
@@ -111,54 +101,6 @@ 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",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
@@ -168,8 +110,7 @@ const mediaMime = (bytes: Uint8Array) => {
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (resource: string, bytes: Uint8Array) => {
if (extensions.has(path.extname(resource).toLowerCase())) return true
const binary = (bytes: Uint8Array) => {
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
@@ -178,21 +119,11 @@ const binary = (resource: string, bytes: Uint8Array) => {
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (resource: string, decoder: TextDecoder, bytes?: Uint8Array) =>
Effect.try({
try: () => decoder.decode(bytes, { stream: bytes !== undefined }),
catch: (error) => {
if (error instanceof TypeError) return new MalformedUtf8Error({ resource })
throw error
},
})
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : decodeUtf8(resource, decoder, bytes)
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* (
fs: { readonly stat: (path: string) => Effect.Effect<{ readonly type: FileType }, FSUtil.Error> },
input: string,
) {
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, 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" }))
@@ -200,7 +131,7 @@ export const inspect = Effect.fn("ReadTool.inspect")(function* (
})
export const read = Effect.fn("ReadTool.read")(function* (
fs: ReadSource,
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
@@ -233,24 +164,25 @@ export const read = Effect.fn("ReadTool.read")(function* (
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(chunks, total).toString("base64"),
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const,
mime,
}
}
if (extensions.has(path.extname(resource).toLowerCase()))
return yield* Effect.fail(new BinaryFileError({ resource }))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(resource, first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* decodeUtf8(resource, decoder, first)]
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(yield* decodeUtf8(resource, decoder))
text.push(decodeUtf8(decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
@@ -263,7 +195,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
const decoder = new TextDecoder()
let pending = ""
let discard = false
let line = 1
@@ -321,8 +253,8 @@ export const read = Effect.fn("ReadTool.read")(function* (
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(resource, segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(yield* decodeUtf8(resource, decoder, segment))) return false
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
start = end
}
return true
@@ -334,7 +266,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = yield* decodeUtf8(resource, decoder)
const tail = decodeUtf8(decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
@@ -354,33 +286,28 @@ 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 entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const absolute = path.join(real, item.name)
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!target || !FSUtil.contains(real, target)) return
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return FileSystem.Entry.make({
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
})
}),
{ concurrency: 16 },
)
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 visible = items
.flatMap((item) =>
item.type === "other"
? []
: [
ListEntry.make({
path: RelativePath.make(item.name + (item.type === "directory" ? path.sep : "")),
type: item.type,
}),
],
)
.sort((a, b) =>
a.type === "directory"
? b.type === "directory"
? a.path.localeCompare(b.path)
: -1
: b.type === "directory"
? 1
: a.path.localeCompare(b.path),
)
const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length
return new ListPage({
@@ -389,7 +316,7 @@ const sortAndPage = (entries: FileSystem.Entry[], page: PageInput) => {
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
}
})
const layer = Layer.effect(
Service,
@@ -404,118 +331,3 @@ 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],
})
+10 -2
View File
@@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
...Object.entries(info.agent ?? {}),
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
]
if (!entries.length) return undefined
return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
const small = modelSelection(info.small_model)
if (!small) return entries.length ? result : undefined
return {
...result,
title: {
model: small,
...result.title,
},
}
}
export function migrateAgent(info: ConfigAgentV1.Info) {
-74
View File
@@ -1,80 +1,6 @@
export * as Workspace from "./workspace"
import { eq } from "drizzle-orm"
import { Context, Effect, Exit, Layer, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "./database/database"
import { WorkspaceDriver } from "./workspace/driver"
import { WorkspaceTable } from "./workspace/sql"
export const ID = Workspace.ID
export type ID = typeof ID.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
provider: Schema.String,
root: AbsolutePath,
}).annotate({ identifier: "Workspace.Info" })
export interface Interface {
/** Allocate through the named driver and persist. Resolves when the Workspace is usable. */
readonly create: (input: {
readonly provider: string
}) => Effect.Effect<Info, WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFoundError>
/** Metadata read; never contacts a driver. */
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
/** Provider key and opaque binding for WorkspaceDriver.connect. Hosted Location construction only. */
readonly binding: (
id: ID,
) => Effect.Effect<{ readonly provider: string; readonly binding: WorkspaceDriver.Binding }, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const registry = yield* WorkspaceDriver.RegistryService
const require = Effect.fn("Workspace.require")(function* (id: ID) {
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ id })
return row
})
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const driver = yield* registry.get(input.provider)
const id = ID.create()
const created = yield* Effect.acquireUseRelease(
driver.create({ workspaceID: id }),
(created) =>
db
.insert(WorkspaceTable)
.values({ id, provider: input.provider, binding: created.binding, root: created.root })
.pipe(Effect.orDie, Effect.as(created)),
(created, exit) => (Exit.isFailure(exit) ? driver.destroy(created.binding).pipe(Effect.ignore) : Effect.void),
)
return Info.make({ id, provider: input.provider, root: AbsolutePath.make(created.root) })
}),
get: Effect.fn("Workspace.get")(function* (id) {
const row = yield* require(id)
return Info.make({ id: row.id, provider: row.provider, root: AbsolutePath.make(row.root) })
}),
binding: Effect.fn("Workspace.binding")(function* (id) {
const row = yield* require(id)
return { provider: row.provider, binding: row.binding }
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, WorkspaceDriver.registryNode] })
-70
View File
@@ -1,70 +0,0 @@
export * as WorkspaceDriver from "./driver"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
/**
* Smallest provider-owned JSON value required to reconnect to the same
* provider resource. Core stores it opaquely and hands it back; only the
* owning driver reads inside.
*/
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceDriver.Error", {
provider: Schema.String,
message: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Interface {
/** Allocate a new environment. Resolves only when it is ready to use. */
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ readonly binding: Binding; readonly root: string }, Error>
/**
* Binding -> live capabilities; the only way to obtain an environment.
* Fresh-create and restart-reconnect both flow through here. Closing the
* scope drops the connection, never the provider resource.
*/
readonly connect: (binding: Binding) => Effect.Effect<WorkspaceEnvironment.Interface, Error, Scope.Scope>
/** Permanently release provider resources. */
readonly destroy: (binding: Binding) => Effect.Effect<void, Error>
}
/** Identity constructor so driver definitions get contextual typing. */
export const make = (driver: Interface) => driver
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
/** Immutable registry fixed at composition time. */
export const registry = (drivers: Readonly<Record<string, Interface>>): Registry => ({
get: (provider) => {
const driver = drivers[provider]
return driver ? Effect.succeed(driver) : Effect.fail(new ProviderNotFoundError({ provider }))
},
})
/**
* Defaults to no drivers, so every provider fails with ProviderNotFoundError.
* Server composition (or a test) replaces this binding with real drivers;
* core never constructs one.
*/
export const registryNode = makeGlobalNode({
service: RegistryService,
layer: Layer.succeed(RegistryService, RegistryService.of(registry({}))),
deps: [],
})
-133
View File
@@ -1,133 +0,0 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { makeLocationNode, tags } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Workspace } from "../workspace"
import { WorkspaceDriver } from "./driver"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
/** Distinct so callers (e.g. the LocationMutation ancestor walk) can catch absence. */
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("WorkspaceEnvironment.NotFoundError", {
path: Schema.String,
}) {}
/** Translate environment failures into the host filesystem error vocabulary. */
export const toFileSystemError =
(method: string) =>
(cause: Error | NotFoundError): FSUtil.Error =>
new FSUtil.FileSystemError({ method, cause })
/**
* Wrap one driver promise, translating the driver's not-found signal into the
* environment error vocabulary so drivers and fakes never construct it ad hoc.
*/
export const tryOperation = <A>(input: {
readonly operation: string
readonly path: string
readonly run: () => Promise<A>
readonly isNotFound: (cause: unknown) => boolean
}): Effect.Effect<A, Error | NotFoundError> =>
Effect.tryPromise({
try: input.run,
catch: (cause) =>
input.isNotFound(cause)
? new NotFoundError({ path: input.path })
: new Error({ operation: input.operation, path: input.path, cause }),
})
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
/**
* The minimal primitive set the hosted implementations of Filesystem,
* LocationMutation, and FileMutation consume. Grow it only when a real
* consumer appears. Every method is one provider round trip: type mismatches
* (read a directory, list a file) fail with Error instead of requiring a
* stat pre-check.
*/
export interface Files {
readonly stat: (path: string) => Effect.Effect<FileInfo, Error | NotFoundError>
/** Canonical path with symlinks resolved; identity for permissions and locking. */
readonly realPath: (path: string) => Effect.Effect<string, Error | NotFoundError>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error | NotFoundError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error | NotFoundError>
/** Creates parent directories, matching FSUtil.writeWithDirs. */
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
/** Removes one file. Removing a missing path fails with NotFoundError. */
readonly remove: (path: string) => Effect.Effect<void, Error | NotFoundError>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
/** The Workspace root, absolute in the provider filesystem. */
readonly directory: string
readonly files: Files
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
/** NotFound becomes undefined; other environment failures become defects. */
export const optional = <A>(effect: Effect.Effect<A, Error | NotFoundError>): Effect.Effect<A | undefined> =>
effect.pipe(
Effect.catchTag("WorkspaceEnvironment.NotFoundError", () => Effect.succeed(undefined)),
Effect.orDie,
)
/** Present only in hosted Location graphs; local graphs never bind it. */
export const node = LayerNode.unbound(Service, tags.values.location)
/**
* Connects on graph boot and stays connected for the graph's cached lifetime:
* Layer.effect supplies the build scope, so releasing the LayerMap entry
* closes the connection. It never stops or deletes the provider resource.
*/
export const hostedNode = (workspaceID: Workspace.ID) =>
makeLocationNode({
service: Service,
// Connect failures during graph build are defects, matching the local
// graph's E = never. Typed availability errors are a later design.
layer: Layer.effect(
Service,
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const registry = yield* WorkspaceDriver.RegistryService
const found = yield* workspaces.binding(workspaceID)
const driver = yield* registry.get(found.provider)
return yield* driver.connect(found.binding)
}),
).pipe(Layer.orDie),
deps: [Workspace.node, WorkspaceDriver.registryNode],
})
/** Identity constructor so environment literals get contextual typing. */
export const make = (environment: Interface) => environment
/** Default lowering for Linux sandbox images. */
export const linuxShell: Shell = {
executable: "/bin/bash",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
}
-12
View File
@@ -1,12 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Workspace } from "@opencode-ai/schema/workspace"
import type { WorkspaceDriver } from "./driver"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
provider: text().notNull(),
binding: text({ mode: "json" }).$type<WorkspaceDriver.Binding>().notNull(),
root: text().notNull(),
...Timestamps,
})
+1
View File
@@ -126,6 +126,7 @@ describe("Agent", () => {
yield* agent.transform((editor) => editor.update(id, () => {}))
const info = yield* agent.get(id)
expect(info?.mode).toBe("primary")
expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual(
Agent.Info.default(id).permissions,
)
+5
View File
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(permissions).toContainEqual({
action: "external_directory",
resource: "C:\\Users\\test\\p\\**",
effect: "allow",
})
expect(
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
+14
View File
@@ -512,6 +512,20 @@ describe("Config", () => {
}),
)
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
@@ -149,6 +149,28 @@ describe("ConfigNormalize", () => {
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("migrates the legacy small model to the title agent", () => {
const result = normalized({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
})
expect(result.encoded.agents).toEqual({
title: {
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
},
})
expect(result.diagnostics).toEqual([])
})
test("omits an invalid legacy small model without exposing its value", () => {
const secret = "do-not-log-this-value"
const result = normalized({ small_model: secret })
expect(result.encoded.agents).toBeUndefined()
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("recovers malformed named entries and retains a valid legacy collision", () => {
const result = normalized({
command: { fallback: { template: "legacy" } },
@@ -390,7 +412,6 @@ describe("ConfigNormalize", () => {
const secret = "do-not-log-this-value"
const result = normalized({
logLevel: "DEBUG",
small_model: secret,
agent: { reviewer: { name: secret, prompt: "review" } },
provider: {
custom: {
@@ -409,7 +430,6 @@ describe("ConfigNormalize", () => {
})
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["logLevel"],
["small_model"],
["agent", "reviewer", "name"],
["provider", "custom", "id"],
["provider", "custom", "whitelist"],
+9 -37
View File
@@ -43,10 +43,9 @@ describe("FileMutation", () => {
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
target: target.canonical,
target: target.absolute,
resource: "hello.txt",
existed: true,
content: "after",
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
}).pipe(provide(directory)),
@@ -63,12 +62,11 @@ describe("FileMutation", () => {
expect(result).toEqual({
operation: "write",
target: target.canonical,
target: target.absolute,
resource: "src/nested/hello.txt",
existed: false,
content: "hello",
})
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
expect(yield* Effect.promise(() => fs.readFile(target.absolute, "utf8"))).toBe("hello")
}).pipe(provide(directory)),
),
)
@@ -82,36 +80,11 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.write({ target: preserved, content: "\uFEFFafter" })
yield* files.write({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
),
)
it.live("moves a file and transfers its BOM to the destination", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fromPath = path.join(directory, "from.txt")
yield* Effect.promise(() => fs.writeFile(fromPath, "\uFEFFbefore"))
const mutation = yield* LocationMutation.Service
const from = yield* mutation.resolve({ path: "from.txt" })
const to = yield* mutation.resolve({ path: "to.txt" })
const result = yield* (yield* FileMutation.Service).move({ from, to, content: "after" })
expect(result.content).toBe("after")
expect(yield* Effect.promise(() => fs.readFile(to.canonical, "utf8"))).toBe("\uFEFFafter")
expect(
yield* Effect.promise(() =>
fs.access(fromPath).then(
() => true,
() => false,
),
),
).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(created.absolute, "utf8"))).toBe("\uFEFFcreated")
}).pipe(provide(directory)),
),
)
@@ -126,10 +99,9 @@ describe("FileMutation", () => {
expect(result).toEqual({
operation: "write",
target: target.canonical,
target: target.absolute,
resource: target.resource,
existed: false,
content: "external",
})
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
}).pipe(provide(directory)),
@@ -137,7 +109,7 @@ describe("FileMutation", () => {
),
)
it.live("serializes concurrent writes to the same canonical target", () =>
it.live("serializes concurrent writes to the same absolute target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "shared.txt")
@@ -180,7 +152,7 @@ describe("FileMutation", () => {
),
)
it.live("allows distinct canonical targets to proceed independently", () =>
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
-161
View File
@@ -1,161 +0,0 @@
import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "fs/promises"
import nodePath from "path"
import { Effect, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
export const ROOT = "/workspace"
export const workspaceID = Workspace.ID.make("wrk_test")
/** A hosted Location at the workspace root, as hostedBoundNode would state it. */
export const hostedLocationLayer = (root: string = ROOT) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: AbsolutePath.make(root),
workspaceID,
project: Project.hostedGlobal(root),
}),
)
export interface MemoryEnvironment {
readonly environment: WorkspaceEnvironment.Interface
/** Live view of stored file contents by absolute path. */
readonly contents: (path: string) => string | undefined
}
/**
* Environment backed by a real host directory and spawner: the honest fake
* for end-to-end tests. Files go through fs/promises, commands actually run
* with the given spawner, so bash output is visible to reads.
*/
export const directoryEnvironment = (
root: string,
spawn: ChildProcessSpawner["Service"]["spawn"],
): WorkspaceEnvironment.Interface => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
WorkspaceEnvironment.tryOperation({
operation,
path,
run,
isNotFound: (cause) => (cause as NodeJS.ErrnoException).code === "ENOENT",
})
return WorkspaceEnvironment.make({
directory: root,
files: {
stat: (path) =>
wrap("stat", path, async () => {
const info = await stat(path)
return {
type: info.isFile()
? ("File" as const)
: info.isDirectory()
? ("Directory" as const)
: ("Unknown" as const),
}
}),
realPath: (path) => wrap("realPath", path, () => realpath(path)),
read: (path) => wrap("read", path, async () => Uint8Array.from(await readFile(path))),
list: (path) =>
wrap("list", path, async () => {
const entries = await readdir(path, { withFileTypes: true })
return entries.map((entry) => ({
name: entry.name,
type: entry.isFile()
? ("file" as const)
: entry.isDirectory()
? ("directory" as const)
: entry.isSymbolicLink()
? ("symlink" as const)
: ("other" as const),
}))
}),
write: (path, content) =>
Effect.tryPromise({
try: async () => {
await mkdir(nodePath.dirname(path), { recursive: true })
await writeFile(path, content)
},
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => rm(path)),
},
process: make(spawn),
shell: {
executable: "/bin/bash",
args: (command) => ["-c", command],
// Real commands need PATH; nothing else from the host environment leaks.
environmentOverrides: { PATH: process.env.PATH ?? "" },
detached: false,
},
})
}
/**
* In-memory workspace environment rooted at /workspace: file paths to
* contents, directories implied by keys, no symlinks, no processes. Type
* mismatches (read a directory, list a file) fail with Error per the Files
* contract.
*/
export const memoryEnvironment = (files: Record<string, string>): MemoryEnvironment => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const store = new Map(Object.entries(files).map(([key, value]) => [key, Uint8Array.from(encoder.encode(value))]))
const isDirectory = (path: string) =>
path === ROOT || Array.from(store.keys()).some((key) => key.startsWith(path + "/"))
const exists = (path: string) => store.has(path) || isDirectory(path)
const notFound = (path: string) => Effect.fail(new WorkspaceEnvironment.NotFoundError({ path }))
const wrongType = (operation: string, path: string) =>
Effect.fail(new WorkspaceEnvironment.Error({ operation, path }))
const environment = WorkspaceEnvironment.make({
directory: ROOT,
files: {
stat: (path) =>
store.has(path)
? Effect.succeed({ type: "File" as const })
: isDirectory(path)
? Effect.succeed({ type: "Directory" as const })
: notFound(path),
realPath: (path) => (exists(path) ? Effect.succeed(path) : notFound(path)),
read: (path) => {
const content = store.get(path)
if (content) return Effect.succeed(content)
return isDirectory(path) ? wrongType("read", path) : notFound(path)
},
list: (path) => {
if (store.has(path)) return wrongType("list", path)
if (!isDirectory(path)) return notFound(path)
const names = new Map<string, "file" | "directory">()
for (const key of store.keys()) {
if (!key.startsWith(path + "/")) continue
const rest = key.slice(path.length + 1)
const [head] = rest.split("/")
if (head) names.set(head, rest.includes("/") ? "directory" : "file")
}
return Effect.succeed(Array.from(names, ([name, type]) => ({ name, type })))
},
write: (path, content) =>
Effect.sync(() => {
store.set(path, Uint8Array.from(content))
}),
remove: (path) => {
if (store.delete(path)) return Effect.void
return isDirectory(path) ? wrongType("remove", path) : notFound(path)
},
},
process: make(() => Effect.die(new Error("no processes in the memory environment"))),
shell: WorkspaceEnvironment.linuxShell,
})
return {
environment,
contents: (path) => {
const stored = store.get(path)
return stored ? decoder.decode(stored) : undefined
},
}
}
+16 -48
View File
@@ -37,7 +37,7 @@ describe("LocationMutation", () => {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
absolute: targetPath,
resource: "hello.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -50,10 +50,8 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const root = yield* Effect.promise(() => fs.realpath(directory))
expect(target).toMatchObject({
canonical: path.join(root, "src", "new.txt"),
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
})
}).pipe(provide(directory)),
@@ -64,9 +62,9 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = yield* Effect.promise(() => fs.realpath(path.dirname(directory)))
const root = path.dirname(directory)
expect(target).toMatchObject({
canonical: path.join(root, "outside.txt"),
absolute: path.join(root, "outside.txt"),
resource: path.join(root, "outside.txt").replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
@@ -77,7 +75,7 @@ describe("LocationMutation", () => {
),
)
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
it.live("resolves a prospective target below an external symlink lexically", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
@@ -88,7 +86,7 @@ describe("LocationMutation", () => {
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -107,7 +105,7 @@ describe("LocationMutation", () => {
})
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
}).pipe(provide(directory)),
@@ -120,7 +118,7 @@ describe("LocationMutation", () => {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
absolute: targetPath,
resource: "new.txt",
})
expect(target.externalDirectory).toBeUndefined()
@@ -134,9 +132,9 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
const root = outside
expect(target).toMatchObject({
canonical: path.join(root, "new.txt"),
absolute: path.join(root, "new.txt"),
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
@@ -155,53 +153,23 @@ describe("LocationMutation", () => {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
expect(target).toMatchObject({ canonical: path.join(root, "existing.txt") })
expect(target.externalDirectory?.directory).toBe(root)
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
),
),
)
it.live("authorizes an external symlink by the named path", () =>
withTmp((directory) =>
withTmp((outside) =>
withTmp((referent) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const targetPath = path.join(referent, "target.txt")
const linkPath = path.join(outside, "link.txt")
yield* Effect.promise(async () => {
await fs.writeFile(targetPath, "content")
await fs.symlink(targetPath, linkPath)
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: linkPath })
expect(target).toMatchObject({
absolute: linkPath,
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
resource: linkPath.replaceAll("\\", "/"),
externalDirectory: {
directory: outside,
resource: path.join(outside, "*").replaceAll("\\", "/"),
},
})
}).pipe(provide(directory)),
),
),
),
)
it.live("anchors prospective external descendants at their stable existing directory", () =>
it.live("authorizes prospective external descendants at their lexical parent", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = yield* Effect.promise(() => fs.realpath(outside))
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: root,
resource: path.join(root, "*").replaceAll("\\", "/"),
directory: parent,
resource: path.join(parent, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
+8 -4
View File
@@ -3,6 +3,7 @@ import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node))
@@ -45,14 +46,17 @@ describe("PTY websocket tickets", () => {
}),
)
it.live("rejects tickets scoped to a different pty", () =>
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID: PtyID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ptyID, workspaceID: Workspace.ID.ascending(), ticket: issued.ticket })).toBe(
false,
)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
})
+4 -2
View File
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { Workspace } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
@@ -126,6 +127,7 @@ describe("Session.create", () => {
it.effect("stores supplied immutable create attributes", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const workspaceID = Workspace.ID.make("wrk_test")
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -134,11 +136,11 @@ describe("Session.create", () => {
expect(
yield* session.create({
location: Location.Ref.make({ directory: location.directory }),
location: Location.Ref.make({ directory: location.directory, workspaceID }),
agent: Agent.ID.make("build"),
model,
}),
).toMatchObject({ location: { directory: location.directory }, agent: "build", model })
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
}),
)
+3 -25
View File
@@ -13,8 +13,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -24,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -606,24 +604,6 @@ describe("PatchTool", () => {
),
)
it.live("preserves a BOM when moving a file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "source.txt")
const moved = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(source, "\uFEFFbefore\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: source.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("\uFEFFafter\n")
}),
),
)
it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
@@ -848,9 +828,7 @@ describe("PatchTool", () => {
),
)
// Paths outside the active Location require external approval — the same
// boundary the edit and write tools derive from LocationMutation.
it.live("requires external approval for sibling paths outside the location", () =>
it.live("treats a sibling path inside the project worktree as internal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -869,7 +847,7 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
@@ -50,22 +51,53 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reports binary and malformed UTF-8 content as typed errors", () =>
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
const malformedContent = new Uint8Array(64 * 1024 + 1).fill(97)
malformedContent[64 * 1024] = 0x80
yield* files.writeFile(malformed, malformedContent)
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedError = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
expect(malformedError).toBeInstanceOf(ReadToolFileSystem.MalformedUtf8Error)
expect(malformedResult).toMatchObject({ type: "file", content: "hi\uFFFD", encoding: "utf8" })
}),
)
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
)
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
{ path: "escape", type: "symlink" },
{ path: "file.txt", type: "file" },
])
}),
)
+18 -20
View File
@@ -148,14 +148,13 @@ const mutation = Layer.succeed(
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const canonical = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
const directory = path.dirname(canonical)
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
const directory = path.dirname(absolute)
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
return Effect.succeed({
canonical,
absolute: canonical,
absolute,
resource,
externalDirectory: external
? {
@@ -622,10 +621,6 @@ describe("ReadTool", () => {
Effect.gen(function* () {
const registry = yield* Tool.Service
for (const [error, message] of [
[
new ReadToolFileSystem.MalformedUtf8Error({ resource: "invalid.txt" }),
"File is not valid UTF-8: invalid.txt",
],
[new ReadToolFileSystem.OffsetOutOfRangeError({ offset: 10 }), "Offset 10 is out of range"],
[
new ReadToolFileSystem.PathKindError({ resource: "socket", expected: "a file" }),
@@ -722,16 +717,19 @@ describe("ReadTool", () => {
const registry = yield* Tool.Service
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
})
expect(result).toMatchObject({ status: "completed", output: { entries: listResult.entries, truncated: true, next: 4 } })
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-read-directory",
name: "read",
input: { path: "src", offset: 2, limit: 10 },
},
})
expect(result).toMatchObject({
status: "completed",
output: { entries: listResult.entries, truncated: true, next: 4 },
})
if (result.status !== "completed") return
expect(result.content).toEqual([
{
+20 -30
View File
@@ -6,9 +6,11 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { GlobTool } from "@opencode-ai/core/tool/plugin/glob"
@@ -22,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node],
deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, FileSystem.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
@@ -172,37 +181,14 @@ describe("search tools", () => {
),
)
it.live("reports the lexical path when grepping a symlinked file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "actual"))
await fs.writeFile(path.join(tmp.path, "actual", "target.txt"), "needle\n")
await fs.symlink(path.join(tmp.path, "actual", "target.txt"), path.join(tmp.path, "link.txt"))
}).pipe(
Effect.andThen(
withTools(tmp.path, (registry) =>
Effect.gen(function* () {
const result = yield* executeTool(registry, call("grep", { path: "link.txt", pattern: "needle" }))
expect(result).toMatchObject({
status: "completed",
output: [{ entry: { path: "link.txt" }, line: 1, text: "needle\n" }],
})
}),
),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("reports no grep matches", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toMatchObject({
@@ -311,7 +297,9 @@ describe("search tools", () => {
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
@@ -343,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
+13 -20
View File
@@ -90,13 +90,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
writeToolNode,
]),
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[FSUtil.node, filesystem],
[Location.node, activeLocation],
@@ -230,7 +224,10 @@ describe("WriteTool", () => {
const deduplicated = path.join(tmp.path, "deduplicated.txt")
formatFile = (target) =>
Effect.promise(async () => {
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
await fs.writeFile(
target,
`\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`,
)
return true
})
return Effect.promise(() =>
@@ -323,24 +320,22 @@ describe("WriteTool", () => {
).pipe(
Effect.andThen((settled) =>
Effect.gen(function* () {
const canonicalTarget = path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "external.txt")
const absoluteTarget = target
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]).toMatchObject({
resources: [
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
],
resources: [path.join(outside.path, "*").replaceAll("\\", "/")],
})
expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] })
expect(assertions[1]).toMatchObject({ resources: [absoluteTarget.replaceAll("\\", "/")], save: ["*"] })
expect(settled).toMatchObject({
status: "completed",
output: {
target: canonicalTarget,
resource: canonicalTarget.replaceAll("\\", "/"),
target: absoluteTarget,
resource: absoluteTarget.replaceAll("\\", "/"),
existed: false,
},
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external")
expect(writes).toEqual([canonicalTarget])
expect(writes).toEqual([absoluteTarget])
}),
),
)
@@ -368,12 +363,10 @@ describe("WriteTool", () => {
),
Effect.andThen(
Effect.gen(function* () {
const canonicalRepo = yield* Effect.promise(() => fs.realpath(repo))
const canonicalNested = yield* Effect.promise(() => fs.realpath(nested))
expect(assertions[0]).toMatchObject({
action: "external_directory",
resources: [path.join(canonicalNested, "*").replaceAll("\\", "/")],
save: [path.join(canonicalRepo, "*").replaceAll("\\", "/")],
resources: [path.join(nested, "*").replaceAll("\\", "/")],
save: [path.join(repo, "*").replaceAll("\\", "/")],
})
}),
),
-150
View File
@@ -1,150 +0,0 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
// Replacement nodes may introduce tagged dependencies; declaring AppProcess
// lets the driver spawn through the real test spawner.
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-e2e-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-db-")), "e2e.db")
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, AppProcess.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
],
),
)
describe("hosted workspace end to end", () => {
it.live("creates, works inside, evicts, and reconnects", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
expect(created.provider).toBe("fake")
// Metadata read round-trips through sqlite without touching the driver.
const fetched = yield* workspaces.get(created.id)
expect(fetched.root).toBe(created.root)
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
yield* Effect.gen(function* () {
const location = yield* Location.Service
expect(location.project.id).toBe(Project.ID.global)
expect(String(location.directory)).toBe(created.root)
const mutation = yield* LocationMutation.Service
const mutations = yield* FileMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
yield* mutations.write({ target, content: "hello from the workspace\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "printf 'from-bash' > bash.txt && ln -s /etc escaped && cat hello.txt",
timeout: 30_000,
})
const finished = yield* shell.wait(command.id)
expect(finished.status).toBe("exited")
const output = yield* shell.output(command.id)
expect(output.output).toContain("hello from the workspace")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
const root = yield* mutation.resolve({ path: "." })
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
const limited = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 1 })
expect(limited).toHaveLength(1)
const matches = yield* filesystem.grep({
target: yield* mutation.resolve({ path: "hello.txt" }),
pattern: "workspace",
limit: 10,
})
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
const invalid = yield* filesystem.grep({ target: root, pattern: "[", limit: 10 }).pipe(Effect.flip)
expect(invalid).toMatchObject({ _tag: "FileSystem.InvalidPatternError", pattern: "[" })
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(1)
// Evict the cached graph, then reconnect through the driver: the
// workspace contents survive because the binding names durable state.
yield* locations.invalidate(ref)
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
expect(new TextDecoder().decode(hello.content)).toBe("hello from the workspace\n")
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("from-bash")
}).pipe(Effect.provide(locations.get(ref)))
expect(connects.count).toBe(2)
}),
)
})
@@ -1,57 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
"/workspace/src/util/deep.ts": "export const deep = 1\n",
})
const it = testEffect(
AppNodeBuilder.build(FileSystem.hostedNode, [
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
]),
)
describe("hosted FileSystem", () => {
it.effect("reads a file through the environment", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const result = yield* filesystem.read({ path: RelativePath.make("README.md") })
expect(new TextDecoder().decode(result.content)).toBe("# hello\n")
expect(result.mime).toBe("text/markdown")
}),
)
it.effect("lists directories before files with posix separators", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list({ path: RelativePath.make("src") })
expect(entries.map((entry) => String(entry.path))).toEqual(["src/util/", "src/index.ts"])
}),
)
it.effect("lists the root when no path is given", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const entries = yield* filesystem.list()
expect(entries.map((entry) => String(entry.path))).toEqual(["src/", "README.md"])
}),
)
it.effect("refuses paths that escape the workspace", () =>
Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const exit = yield* filesystem.read({ path: RelativePath.make("../etc/passwd") }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})
@@ -1,79 +0,0 @@
import { describe, expect, test } from "bun:test"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { hostedReplacements, locationServices } from "@opencode-ai/core/location-services"
import { Workspace } from "@opencode-ai/core/workspace"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
// Location-scoped services receive Location-relative and Location-absolute
// paths from tools. In a hosted graph those are provider paths (for example
// /workspace/src), so any Location-scoped service that still depends on a
// host primitive would silently run provider paths against the host machine.
// This is the guardrail for the class of bug where the read tool's filesystem
// stayed host-backed after the hosted rollout.
//
// Every entry here is a documented decision, not an exemption by default.
// Adding a new Location-scoped service that touches FSUtil or AppProcess
// fails this test until the service either gets a hosted replacement in
// hostedReplacements or is justified below.
const allowed: Record<string, string> = {
// Host-side by design: hosted graphs read only global (host) configuration.
"@opencode/Config": "global host config sources only (Config.configured({ project: false }))",
"@opencode/InstructionDiscovery": "global host instructions only (configured({ project: false }))",
"@opencode/Skill": "skills load from host config directories",
"@opencode/PluginSupervisor": "plugins are host processes loaded from host paths",
"@opencode/Integration": "integrations run host-side CLIs",
"@opencode/ProjectCopy": "project copy refresh operates on host-owned global project data",
// Known gaps: these never advertise hosted capability, but their services
// still exist in hosted graphs and would touch host paths if invoked with
// provider paths. Each needs an environment-backed design or an explicit
// hosted no-op before workspaces ship publicly.
"@opencode/Command": "TODO: `!command` template substitution would run on the host",
"@opencode/LocationWatcher": "TODO: watches the provider directory path on the host filesystem",
"@opencode/Snapshot": "TODO: hosted snapshots need environment-backed git",
"@opencode/Vcs": "TODO: hosted VCS info is stated by Project.hostedGlobal, service methods remain host git",
"@opencode/v2/Formatter": "TODO: formatters are host binaries; hosted FileMutation deliberately omits format",
"session-instructions": "TODO: per-session instruction file loads read the host filesystem",
}
describe("hosted location graph", () => {
test("location-scoped services do not use host primitives without justification", () => {
const workspaceID = Workspace.ID.make("wrk_graphguard000000000000000")
const ref = Location.Ref.make({ directory: AbsolutePath.make("/workspace"), workspaceID })
// Replacement targets may be bare layers in general; every hosted
// replacement is a node today, and bare-layer targets have no walkable
// dependencies anyway.
const replaced = new Map<LayerNode.AnyNode, LayerNode.AnyNode>(
hostedReplacements(ref, workspaceID).flatMap(([from, to]) => (LayerNode.isNode(to) ? [[from, to] as const] : [])),
)
const resolve = (node: LayerNode.AnyNode) => replaced.get(node) ?? node
const hostPrimitives = new Set<LayerNode.AnyNode>([FSUtil.node, AppProcess.node])
const offenders = new Map<string, string[]>()
LayerNode.walk<void>(
locationServices,
(node, context) => {
for (const dependency of node.dependencies) {
if (hostPrimitives.has(resolve(dependency)) && node.tag === Node.tags.values.location) {
offenders.set(node.name, [...(offenders.get(node.name) ?? []), resolve(dependency).name])
}
context.visit(dependency)
}
},
{ resolve },
)
const unjustified = [...offenders.entries()]
.filter(([name]) => !(name in allowed))
.map(([name, primitives]) => `${name} -> ${primitives.join(", ")}`)
.sort()
expect(unjustified).toEqual([])
// Stale allowlist entries must be removed so the inventory stays honest.
const stale = Object.keys(allowed).filter((name) => !offenders.has(name))
expect(stale).toEqual([])
})
})
@@ -1,95 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
import { hostedLocationLayer, memoryEnvironment } from "./lib/workspace"
const memory = memoryEnvironment({
"/workspace/README.md": "# hello\n",
"/workspace/src/index.ts": "export {}\n",
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([LocationMutation.hostedNode, FileMutation.hostedNode, ReadToolFileSystem.hostedNode]),
[
[WorkspaceEnvironment.node, Layer.succeed(WorkspaceEnvironment.Service, memory.environment)],
[Location.node, hostedLocationLayer()],
],
),
)
describe("hosted mutation", () => {
it.effect("resolves an existing file to its canonical path and relative resource", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "README.md" })
expect(target.canonical).toBe("/workspace/README.md")
expect(target.resource).toBe("README.md")
expect(target.externalDirectory).toBeUndefined()
}),
)
it.effect("resolves a missing path below an existing directory", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const target = yield* mutation.resolve({ path: "src/created/new.ts" })
expect(target.canonical).toBe("/workspace/src/created/new.ts")
expect(target.resource).toBe("src/created/new.ts")
}),
)
it.effect("rejects paths outside the workspace instead of granting external access", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const error = yield* mutation.resolve({ path: "/etc/passwd" }).pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}),
)
it.effect("writes new and existing files through the environment", () =>
Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const created = yield* mutation.resolve({ path: "notes/todo.md" })
const first = yield* files.write({ target: created, content: "- ship it\n" })
expect(first.existed).toBe(false)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- ship it\n")
const second = yield* files.write({ target: created, content: "- shipped\n" })
expect(second.existed).toBe(true)
expect(memory.contents("/workspace/notes/todo.md")).toBe("- shipped\n")
}),
)
// The reader must consume the environment, never the host filesystem:
// these paths exist only in the memory store.
it.effect("reads files and directories through the environment", () =>
Effect.gen(function* () {
const reader = yield* ReadToolFileSystem.Service
expect(yield* reader.inspect(AbsolutePath.make("/workspace/README.md"))).toBe("file")
expect(yield* reader.inspect(AbsolutePath.make("/workspace/src"))).toBe("directory")
const content = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md")
expect(content).toMatchObject({ type: "file", content: "# hello\n", encoding: "utf8" })
const paged = yield* reader.read(AbsolutePath.make("/workspace/README.md"), "README.md", { limit: 1 })
expect(paged).toMatchObject({ type: "text-page", content: "# hello", offset: 1, truncated: false })
const listed = yield* reader.list(AbsolutePath.make("/workspace/src"))
expect(listed.entries.map((entry) => String(entry.path))).toEqual(["index.ts"])
const missing = yield* reader.inspect(AbsolutePath.make("/workspace/nope.txt")).pipe(Effect.flip)
expect(missing).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } })
}),
)
})
-175
View File
@@ -1,175 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { PatchTool } from "@opencode-ai/core/tool/plugin/patch"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin } from "./lib/tool"
import { hostedLocationLayer, memoryEnvironment, ROOT } from "./lib/workspace"
import type { MemoryEnvironment } from "./lib/workspace"
const sessionID = Session.ID.make("ses_workspace_patch_test")
const assertions: Permission.AssertInput[] = []
const permission = Layer.succeed(
Permission.Service,
Permission.Service.of({
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const withTool = <A, E, R>(memory: MemoryEnvironment, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const environment = Layer.succeed(WorkspaceEnvironment.Service, memory.environment)
const patchToolNode = makeLocationNode({
name: "test/workspace-patch-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
})
return Effect.gen(function* () {
assertions.length = 0
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[Location.node, hostedLocationLayer()],
[LocationMutation.node, LocationMutation.hostedNode],
[FileMutation.node, FileMutation.hostedNode],
[Permission.node, permission],
[WorkspaceEnvironment.node, environment],
]),
),
)
}
const call = (patchText: string, id = "call-patch") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "patch", input: { patchText } },
})
const it = testEffect(Layer.empty)
describe("PatchTool on a hosted location", () => {
it.effect("applies add, update, and delete through the workspace environment", () => {
const memory = memoryEnvironment({
[`${ROOT}/update.txt`]: "before\n",
[`${ROOT}/remove.txt`]: "remove\n",
})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
)
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.output).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt", target: `${ROOT}/nested/new.txt` },
{ type: "update", resource: "update.txt" },
{ type: "delete", resource: "remove.txt" },
],
})
expect(memory.contents(`${ROOT}/nested/new.txt`)).toBe("created\n")
expect(memory.contents(`${ROOT}/update.txt`)).toBe("after\n")
expect(memory.contents(`${ROOT}/remove.txt`)).toBeUndefined()
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["nested/new.txt", "update.txt", "remove.txt"])
}),
)
})
it.effect("moves a file through the workspace environment", () => {
const memory = memoryEnvironment({ [`${ROOT}/old.txt`]: "before\n" })
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("completed")
expect(memory.contents(`${ROOT}/old.txt`)).toBeUndefined()
expect(memory.contents(`${ROOT}/moved.txt`)).toBe("after\n")
}),
)
})
it.effect("reports a missing update target as a verification failure", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("file does not exist")
}),
)
})
it.effect("reports hosted write failures through the tool error channel", () => {
const base = memoryEnvironment({})
const memory: MemoryEnvironment = {
...base,
environment: WorkspaceEnvironment.make({
...base.environment,
files: {
...base.environment.files,
write: (path, content) =>
path.endsWith("fail.txt")
? Effect.fail(new WorkspaceEnvironment.Error({ operation: "write", path }))
: base.environment.files.write(path, content),
},
}),
}
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: fail.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("Failed to write fail.txt")
}),
)
})
it.effect("rejects targets outside the workspace", () => {
const memory = memoryEnvironment({})
return withTool(memory, (registry) =>
Effect.gen(function* () {
const settled = yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: /outside/new.txt\n+created\n*** End Patch"),
)
expect(settled.status).toBe("error")
if (settled.status !== "error") return
expect(settled.error?.message).toContain("path is outside the workspace")
expect(assertions).toEqual([])
expect(memory.contents("/outside/new.txt")).toBeUndefined()
}),
)
})
})
@@ -1,187 +0,0 @@
import { describe, expect } from "bun:test"
import { mkdtempSync } from "fs"
import { mkdtemp, readFile } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { AppProcess } from "@opencode-ai/util/process"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { testEffect } from "./lib/effect"
import { directoryEnvironment } from "./lib/workspace"
const FakeBinding = Schema.Struct({ root: Schema.String })
const connects = { count: 0 }
const registryLayer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const appProcess = yield* AppProcess.Service
const driver = WorkspaceDriver.make({
create: () =>
Effect.promise(async () => {
const root = await mkdtemp(path.join(tmpdir(), "opencode-workspace-session-"))
return { binding: { root }, root }
}),
connect: (binding) =>
Schema.decodeUnknownEffect(FakeBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.Error({ provider: "fake", cause })),
Effect.map((decoded) => {
connects.count++
return directoryEnvironment(decoded.root, (command) => appProcess.spawn(command))
}),
),
destroy: () => Effect.void,
})
return WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ fake: driver }))
}),
)
const registry = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: registryLayer,
deps: [AppProcess.node],
})
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
// the only editor, matching production GPT-5 sessions.
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
})
// Real permission flow would ask; the runner path under test is admission
// through drain, so approve everything.
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
// Outer and hoisted location graphs must share one durable database, like the
// production file-backed configuration; :memory: would give each its own.
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-workspace-session-db-")), "e2e.db")
// The real SessionExecution + Session nodes: drains route through
// LocationServiceMap.get(session.location), which is the seam under test.
// Replacements flow into every hoisted per-location graph via AppNodeBuilder.
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
AppProcess.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, registry],
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
[SessionRunnerModel.node, models],
[Permission.node, permission],
],
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] }))),
)
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
describe("hosted workspace session", () => {
it.live("runs a scripted model through the real runner on a hosted Location", () =>
Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "fake" })
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "hosted session",
location: Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
}),
model: sessionModel,
})
yield* TestLLM.push(
TestLLM.tool("call-patch", "patch", {
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
}),
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
yield* sessions.resume(session.id)
// Both tools executed inside the workspace, not on a host path.
const written = yield* Effect.promise(() => readFile(path.join(created.root, "from-model.txt"), "utf8"))
expect(written).toBe("from-model")
const patched = yield* Effect.promise(() => readFile(path.join(created.root, "from-patch.txt"), "utf8"))
expect(patched).toBe("patched\n")
// The hosted catalog includes search tools now that they execute through
// the Workspace process seam, plus patch as the gpt-style editor.
const requests = (yield* TestLLM.Service).requests
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
expect(advertised).toContain("shell")
expect(advertised).toContain("patch")
expect(advertised).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
// Projected history shows the completed tool call and the final text.
// Instruction updates (e.g. the Code Mode catalog settling after boot)
// may interleave, so assert on the user and assistant messages directly.
const context = yield* sessions.context(session.id)
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
const assistants = context.filter((message) => message.type === "assistant")
expect(assistants.at(0)).toMatchObject({
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
})
expect(assistants.at(1)).toMatchObject({
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
})
expect(assistants.at(2)).toMatchObject({
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
})
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
expect(connects.count).toBe(1)
}),
)
})
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { isSafeExternalUrl } from "./external-url"
describe("external URL policy", () => {
test.each(["https://opencode.ai", "http://127.0.0.1:4096", "mailto:hello@opencode.ai"])("allows %s", (url) =>
expect(isSafeExternalUrl(url)).toBe(true),
)
test.each(["file:///tmp/test", "javascript:alert(1)", "data:text/html,test", "vscode://file/tmp/test", "nope"])(
"rejects %s",
(url) => expect(isSafeExternalUrl(url)).toBe(false),
)
test("rejects non-string values", () => {
expect(isSafeExternalUrl(null)).toBe(false)
expect(isSafeExternalUrl({ toString: () => "https://opencode.ai" })).toBe(false)
})
})
@@ -0,0 +1,6 @@
const protocols = new Set(["http:", "https:", "mailto:"])
export function isSafeExternalUrl(value: unknown) {
if (typeof value !== "string" || !URL.canParse(value)) return false
return protocols.has(new URL(value).protocol)
}
+2
View File
@@ -8,6 +8,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { isSafeExternalUrl } from "./external-url"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
@@ -178,6 +179,7 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
if (!isSafeExternalUrl(url)) return
void shell.openExternal(url)
})
+5
View File
@@ -220,6 +220,11 @@ export function createMainWindow(id: string = randomUUID()) {
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
win.webContents.on("will-frame-navigate", (event) => {
if (isRendererUrl(event.url)) return
event.preventDefault()
})
win.webContents.setWindowOpenHandler(() => ({ action: "deny" }))
loadWindow(win, "index.html")
wireZoom(win)
+33
View File
@@ -23,11 +23,44 @@ export type ProviderContext = {
options: Record<string, any>
}
export type WorkspaceInfo = {
id: string
type: string
name: string
branch: string | null
directory: string | null
extra: unknown | null
projectID: string
}
export type WorkspaceTarget =
| {
type: "local"
directory: string
}
| {
type: "remote"
url: string | URL
headers?: HeadersInit
}
export type WorkspaceAdapter = {
name: string
description: string
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
}
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
project: Project
directory: string
worktree: string
experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void
}
serverUrl: URL
$: BunShell
}
+1
View File
@@ -6,6 +6,7 @@ export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
+1 -1
View File
@@ -130,7 +130,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
"x-websocket": true,
parameters: [
...(operation.parameters ?? []),
...["location[directory]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
+2 -1
View File
@@ -7,6 +7,7 @@ import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
@@ -42,6 +43,7 @@ const ParentIDFilter = Schema.Union([
})
const SessionsQueryFields = {
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
@@ -154,7 +156,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: InvalidRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
@@ -6,6 +6,7 @@ import { Session } from "@opencode-ai/schema/session"
describe("SessionsCursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
+1 -1
View File
@@ -41,7 +41,7 @@ export const Info = Schema.Struct({
id,
name: Name.make(id),
request: { settings: {}, headers: {}, body: {} },
mode: "all",
mode: "primary",
hidden: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
+2
View File
@@ -31,6 +31,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionStatusEvent } from "./session-status-event.js"
import { TuiEvent } from "./tui-event.js"
import { VcsEvent } from "./vcs-event.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorktreeEvent } from "./worktree-event.js"
import { WebSearch } from "./websearch.js"
@@ -88,6 +89,7 @@ export const Definitions = Event.inventory(
...SessionStatusEvent.Definitions,
...SessionCompactionEvent.Definitions,
...VcsEvent.Definitions,
...WorkspaceEvent.Definitions,
...WorktreeEvent.Definitions,
...ServerEvent.Definitions,
)
+32
View File
@@ -0,0 +1,32 @@
export * as WorkspaceEvent from "./workspace-event.js"
import { Schema } from "effect"
import { Event } from "./event.js"
import { WorkspaceID } from "./workspace-id.js"
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
}).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" })
export interface ConnectionStatus extends Schema.Schema.Type<typeof ConnectionStatus> {}
export const Ready = Event.ephemeral({
type: "workspace.ready",
schema: {
name: Schema.String,
},
})
export const Failed = Event.ephemeral({
type: "workspace.failed",
schema: {
message: Schema.String,
},
})
export const Status = Event.ephemeral({
type: "workspace.status",
schema: ConnectionStatus.fields,
})
export const Definitions = Event.inventory(Ready, Failed, Status)
+3
View File
@@ -1,6 +1,9 @@
export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js"
export const ID = WorkspaceID
export type ID = WorkspaceID
export const Event = WorkspaceEvent
+4 -1
View File
@@ -9,6 +9,7 @@ import {
Project,
Reference,
Session,
Workspace,
} from "../src/index.js"
import { EventManifest } from "../src/event-manifest.js"
import { FileSystemV1 } from "../src/filesystem-v1.js"
@@ -18,7 +19,7 @@ import { Plugin } from "../src/plugin.js"
import { SessionEvent } from "../src/session-event.js"
import { SessionID } from "../src/session-id.js"
import { SessionMessage } from "../src/session-message.js"
import { WorkspaceEvent } from "../src/workspace-event.js"
describe("public event manifest", () => {
test("owns the complete public event surface", () => {
@@ -47,6 +48,8 @@ describe("public event manifest", () => {
test("uses canonical definitions for current public events", () => {
expect(Session.Event).toBe(SessionEvent)
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
expect(Workspace.Event).toBe(WorkspaceEvent)
expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
expect(EventManifest.Latest.get("session.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
+1 -3
View File
@@ -20,11 +20,9 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"modal": "0.9.0"
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+1
View File
@@ -10,6 +10,7 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { PtyEnvironment } from "../pty-environment"
const ticketScope = Effect.gen(function* () {
const location = yield* Location.Service
return { directory: location.directory as string }
return { directory: location.directory as string, workspaceID: location.workspaceID }
})
export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
+3 -12
View File
@@ -39,6 +39,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
: ctx.query
const page = yield* session.list({
...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit,
})
const sessions = page.data
@@ -83,15 +84,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(
Effect.catchTags({
"Workspace.NotFoundError": (error) =>
Effect.fail(new InvalidRequestError({ message: `Workspace not found: ${error.id}` })),
"Session.WorkspaceDirectoryError": (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
}),
Effect.orDie,
),
.pipe(Effect.orDie),
}
}),
)
@@ -272,6 +265,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.move({
sessionID: ctx.params.sessionID,
directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
@@ -462,9 +456,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
),
Effect.catchTag("Shell.InvalidCwdError", (error) =>
Effect.fail(new InvalidRequestError({ message: error.message })),
),
)
return HttpApiSchema.NoContent.make()
}),
+2 -6
View File
@@ -2,7 +2,7 @@ import { Shell } from "@opencode-ai/core/shell"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InvalidRequestError, ShellNotFoundError } from "@opencode-ai/protocol/errors"
import { ShellNotFoundError } from "@opencode-ai/protocol/errors"
import { Api } from "../api"
import { response } from "../location"
@@ -21,11 +21,7 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
Effect.fn(function* (ctx) {
const shell = yield* Shell.Service
const location = yield* Location.Service
return yield* response(
shell
.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory })
.pipe(Effect.mapError((error) => new InvalidRequestError({ message: error.message }))),
)
return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }))
}),
)
.handle(
+3
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -27,11 +28,13 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
const directory =
query.get("location[directory]") ||
(request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
return Location.Ref.make({
directory: AbsolutePath.make(directory),
workspaceID: workspaceID ? Workspace.ID.make(workspaceID) : undefined,
})
}
@@ -4,6 +4,7 @@ import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Workspace } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
@@ -39,7 +40,7 @@ export const sessionLocationLayer = Layer.effect(
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspace_id: SessionTable.workspace_id })
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
@@ -53,11 +54,9 @@ export const sessionLocationLayer = Layer.effect(
return yield* effect.pipe(
Effect.provide(
locations.get(
// Hosted-ness is a server-side fact from the session row; clients
// never assert workspace identity per-request.
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ?? undefined,
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
}),
),
),
-3
View File
@@ -41,8 +41,6 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ServerWorkspaceDrivers } from "./workspace/drivers"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
@@ -88,7 +86,6 @@ function makeRoutes<AuthError, AuthServices>(
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
-18
View File
@@ -1,18 +0,0 @@
export * as ServerWorkspaceDrivers from "./drivers"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { ModalDriver } from "./modal"
/** Replaces core's empty default registry in Server composition. */
export const node = makeGlobalNode({
service: WorkspaceDriver.RegistryService,
layer: Layer.effect(
WorkspaceDriver.RegistryService,
Effect.map(ModalDriver.make, (modal) =>
WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ modal })),
),
),
deps: [],
})
-201
View File
@@ -1,201 +0,0 @@
export * as ModalDriver from "./modal"
import { Effect, Schema, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command } from "effect/unstable/process/ChildProcess"
import { ExitCode, ProcessId, make as makeSpawner, makeHandle } from "effect/unstable/process/ChildProcessSpawner"
import { ModalClient, type Sandbox, SandboxFilesystemNotFoundError, NotFoundError } from "modal"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
const PROVIDER = "modal"
const APP = "opencode-workspaces"
const ROOT = "/workspace"
/** Maximum sandbox lifetime; stop/resume/TTL lifecycle is a deferred design. */
const SANDBOX_TIMEOUT_MS = 60 * 60 * 1000
/**
* Backstop for commands: Modal exposes no per-process termination, so kill is
* best-effort and this deadline is what actually reaps orphaned commands.
*/
const EXEC_TIMEOUT_MS = 30 * 60 * 1000
const Binding = Schema.Struct({ sandboxId: Schema.String })
const fail = (message: string) => (cause: unknown) => new WorkspaceDriver.Error({ provider: PROVIDER, message, cause })
// git, bash, and rg are provisioned in the image, never discovered.
const IMAGE_COMMANDS = ["RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep ca-certificates"]
export const make = Effect.sync(() => {
// Credentials come from MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml.
const client = new ModalClient()
const decode = (binding: WorkspaceDriver.Binding) =>
Schema.decodeUnknownEffect(Binding)(binding).pipe(Effect.mapError(fail("invalid modal binding")))
return WorkspaceDriver.make({
create: ({ workspaceID }) =>
Effect.tryPromise({
try: async () => {
const app = await client.apps.fromName(APP, { createIfMissing: true })
const image = client.images.fromRegistry("ubuntu:24.04").dockerfileCommands(IMAGE_COMMANDS)
const sandbox = await client.sandboxes.create(app, image, {
name: workspaceID,
timeoutMs: SANDBOX_TIMEOUT_MS,
})
await sandbox.filesystem.makeDirectory(ROOT)
return { binding: { sandboxId: sandbox.sandboxId }, root: ROOT }
},
catch: fail("create"),
}),
connect: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
const sandbox = yield* Effect.tryPromise({
try: () => client.sandboxes.fromId(decoded.sandboxId),
catch: fail("connect"),
})
const exit = yield* Effect.tryPromise({ try: () => sandbox.poll(), catch: fail("connect") })
if (exit !== null)
return yield* new WorkspaceDriver.Error({
provider: PROVIDER,
message: `sandbox ${decoded.sandboxId} has terminated (exit ${exit})`,
})
return environment(sandbox)
}),
destroy: (binding) =>
Effect.gen(function* () {
const decoded = yield* decode(binding)
yield* Effect.tryPromise({
try: async () => {
const sandbox = await client.sandboxes.fromId(decoded.sandboxId)
await sandbox.terminate({ wait: true })
},
catch: fail("destroy"),
}).pipe(
// Already gone is destroyed.
Effect.catchIf(
(error) => error.cause instanceof NotFoundError,
() => Effect.void,
),
)
}),
})
})
const environment = (sandbox: Sandbox): WorkspaceEnvironment.Interface => ({
directory: ROOT,
files: files(sandbox),
process: makeSpawner(spawn(sandbox)),
shell: WorkspaceEnvironment.linuxShell,
})
const FILE_TYPE = { file: "File", directory: "Directory", symlink: "SymbolicLink" } as const
const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
const wrap = <A>(operation: string, path: string, run: () => Promise<A>) =>
WorkspaceEnvironment.tryOperation({
operation,
path,
run,
isNotFound: (cause) => cause instanceof SandboxFilesystemNotFoundError,
})
return {
stat: (path) =>
wrap("stat", path, async () => {
const info = await sandbox.filesystem.stat(path)
return { type: FILE_TYPE[info.type] }
}),
realPath: (path) =>
// No canonicalization API; realpath(1) from coreutils. A non-zero exit
// is reported as NotFound, which matches the dominant failure mode.
wrap("realPath", path, async () => {
// Both streams piped: the SDK's "ignore" path needs ReadableStream.from,
// which Bun does not implement.
// -e: every component must exist, matching node fs.realpath semantics.
const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" })
const [output, code] = await Promise.all([process.stdout.readText(), process.wait()])
if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`)
return output.trim()
}),
read: (path) => wrap("read", path, () => sandbox.filesystem.readBytes(path)),
list: (path) =>
wrap("list", path, async () => {
const entries = await sandbox.filesystem.listFiles(path)
return entries.map((entry) => ({
name: entry.name,
type:
entry.type === "file"
? ("file" as const)
: entry.type === "directory"
? ("directory" as const)
: ("symlink" as const),
}))
}),
// Parent directories are created by the SDK, matching the Files contract.
write: (path, content) =>
Effect.tryPromise({
try: () => sandbox.filesystem.writeBytes(content, path),
catch: (cause) => new WorkspaceEnvironment.Error({ operation: "write", path, cause }),
}),
remove: (path) => wrap("remove", path, () => sandbox.filesystem.remove(path)),
}
}
const spawnError = (method: string, options?: { description?: string; cause?: unknown }) =>
systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options })
const spawn = (sandbox: Sandbox) => {
let pids = 0
return Effect.fnUntraced(function* (command: Command) {
if (command._tag === "PipedCommand")
return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" }))
const process = yield* Effect.tryPromise({
try: () =>
sandbox.exec([command.command, ...command.args], {
mode: "binary",
stdout: "pipe",
stderr: "pipe",
workdir: command.options.cwd,
env: compact(command.options.env),
timeoutMs: EXEC_TIMEOUT_MS,
}),
catch: (cause) => spawnError("spawn", { cause }),
})
let exited = false
let waited: Promise<number> | undefined
const wait = () =>
(waited ??= process.wait().then((code) => {
exited = true
return code
}))
const onError = (cause: unknown) => spawnError("process", { cause })
return makeHandle({
pid: ProcessId(++pids),
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
isRunning: Effect.sync(() => !exited),
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
stdin: Sink.forEach((chunk: Uint8Array) =>
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
),
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
all: Stream.merge(
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
),
getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })),
getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })),
unref: Effect.succeed(Effect.void),
})
})
}
const compact = (env: Record<string, string | undefined> | undefined) => {
if (!env) return undefined
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
}
@@ -1,100 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db")
const layer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
],
)
// The complete hosted stack against real Modal: workspace creation, the full
// Location graph, file mutation, real bash in the sandbox, eviction, reconnect.
describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => {
test(
"runs the location graph against a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
const locations = yield* LocationServiceMap.Service
const ref = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
yield* Effect.gen(function* () {
const location = yield* Location.Service
expect(location.project.id).toBe(Project.ID.global)
const mutation = yield* LocationMutation.Service
const mutations = yield* FileMutation.Service
const target = yield* mutation.resolve({ path: "hello.txt" })
yield* mutations.write({ target, content: "hello from opencode\n" })
const shell = yield* Shell.Service
const command = yield* shell.create({
command: "cat hello.txt && printf 'bash-made' > bash.txt && ln -s /etc escaped",
timeout: 60_000,
})
const finished = yield* shell.wait(command.id)
expect(finished.status).toBe("exited")
const output = yield* shell.output(command.id)
expect(output.output).toContain("hello from opencode")
const filesystem = yield* FileSystem.Service
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made")
const root = yield* mutation.resolve({ path: "." })
const entries = yield* filesystem.glob({ target: root, pattern: "*.txt", limit: 10 })
expect(entries.map((entry) => String(entry.path)).sort()).toEqual(["bash.txt", "hello.txt"])
const matches = yield* filesystem.grep({
target: yield* mutation.resolve({ path: "hello.txt" }),
pattern: "opencode",
limit: 10,
})
expect(matches).toMatchObject([{ entry: { path: "hello.txt" }, line: 1 }])
const escaped = yield* mutation.resolve({ path: "escaped/passwd" }).pipe(Effect.flip)
expect(escaped).toMatchObject({ _tag: "LocationMutation.PathError", reason: "outside_workspace" })
}).pipe(Effect.provide(locations.get(ref)))
yield* locations.invalidate(ref)
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
expect(new TextDecoder().decode(hello.content)).toBe("hello from opencode\n")
}).pipe(Effect.provide(locations.get(ref)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -1,153 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync } from "fs"
import { homedir, tmpdir } from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-session-")), "session.db")
// A gpt-style id so the patch plugin's context hook advertises apply_patch as
// the only editor, matching production GPT-5 sessions.
const model = LanguageModel.make({ id: "gpt-fake", provider: "fake", route: OpenAIChat.route })
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
}),
),
})
const permission = Layer.mock(Permission.Service)({ assert: () => Effect.void })
const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
Workspace.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
SessionExecution.node,
Session.node,
]),
[
[Database.node, Database.configured({ path: databaseFile })],
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
[LayerNodePlatform.llmClient, TestLLM.clientLayer],
[SessionRunnerModel.node, models],
[Permission.node, permission],
],
).pipe(Layer.provideMerge(TestLLM.layer({ fallback: [] })))
const sessionModel = Model.Ref.make({ id: Model.ID.make("gpt-fake"), providerID: Provider.ID.make("fake") })
// A scripted model through the real runner against a real Modal sandbox: the
// session-level counterpart of workspace-modal-graph.test.ts.
describe.skipIf(!hasCredentials)("hosted session on modal (live)", () => {
test(
"runs a scripted model through the real runner on a modal sandbox",
async () => {
await Effect.gen(function* () {
const workspaces = yield* Workspace.Service
const created = yield* workspaces.create({ provider: "modal" })
const driver = yield* ModalDriver.make
const found = yield* workspaces.binding(created.id)
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
const sessions = yield* Session.Service
const location = Location.Ref.make({
directory: AbsolutePath.make(created.root),
workspaceID: created.id,
})
const session = yield* sessions.create({
title: "hosted modal session",
location,
model: sessionModel,
})
yield* TestLLM.push(
TestLLM.tool("call-patch", "patch", {
patchText: "*** Begin Patch\n*** Add File: from-patch.txt\n+patched\n*** End Patch",
}),
TestLLM.tool("call-shell", "shell", { command: "printf 'from-model' > from-model.txt" }),
TestLLM.tool("call-glob", "glob", { pattern: "*.txt" }),
TestLLM.tool("call-grep", "grep", { pattern: "from-model" }),
// Read must go through the sandbox filesystem: the host has no
// /workspace/from-patch.txt, so a host-backed reader would fail.
TestLLM.tool("call-read", "read", { path: "from-patch.txt" }),
TestLLM.text("done", "text-1"),
)
yield* sessions.prompt({ sessionID: session.id, text: "Write a file in the workspace", resume: false })
yield* sessions.resume(session.id)
const requests = (yield* TestLLM.Service).requests
const advertised = requests[0]?.tools.map((tool) => tool.name) ?? []
expect(advertised).toContain("shell")
expect(advertised).toContain("patch")
expect(advertised).toContain("glob")
expect(advertised).toContain("grep")
expect(advertised).toContain("read")
expect(advertised).not.toContain("edit")
expect(advertised).not.toContain("write")
const context = yield* sessions.context(session.id)
expect(context.at(0)).toMatchObject({ type: "user", text: "Write a file in the workspace" })
const assistants = context.filter((message) => message.type === "assistant")
expect(assistants.at(0)).toMatchObject({
content: [{ type: "tool", id: "call-patch", state: { status: "completed" } }],
})
expect(assistants.at(1)).toMatchObject({
content: [{ type: "tool", id: "call-shell", state: { status: "completed" } }],
})
expect(assistants.at(2)).toMatchObject({
content: [{ type: "tool", id: "call-glob", state: { status: "completed" } }],
})
expect(assistants.at(3)).toMatchObject({
content: [{ type: "tool", id: "call-grep", state: { status: "completed" } }],
})
expect(assistants.at(4)).toMatchObject({
content: [{ type: "tool", id: "call-read", state: { status: "completed" } }],
})
expect(assistants.at(-1)).toMatchObject({ content: [{ type: "text", text: "done" }] })
// Both tools executed inside the sandbox: read the files back through
// the hosted filesystem rather than any host path.
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const filesystem = yield* FileSystem.Service
const written = yield* filesystem.read({ path: RelativePath.make("from-model.txt") })
expect(new TextDecoder().decode(written.content)).toBe("from-model")
const patched = yield* filesystem.read({ path: RelativePath.make("from-patch.txt") })
expect(new TextDecoder().decode(patched.content)).toBe("patched\n")
}).pipe(Effect.provide(locations.get(location)))
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
},
{ timeout: 600_000 },
)
})
@@ -1,80 +0,0 @@
import { describe, expect, test } from "bun:test"
import { existsSync } from "fs"
import { homedir } from "os"
import path from "path"
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Workspace } from "@opencode-ai/core/workspace"
import { ModalDriver } from "../src/workspace/modal"
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
// Live contract test against real Modal. First run may build the image
// (apt-get install), so the budget is generous.
describe.skipIf(!hasCredentials)("modal driver (live)", () => {
test(
"create, connect, files, exec, reconnect, destroy",
async () => {
await Effect.gen(function* () {
const driver = yield* ModalDriver.make
const workspaceID = Workspace.ID.create()
const created = yield* driver.create({ workspaceID })
expect(created.root).toBe("/workspace")
const cleanup = () => Effect.ignore(driver.destroy(created.binding))
yield* Effect.addFinalizer(cleanup)
const env = yield* driver.connect(created.binding)
expect(env.directory).toBe("/workspace")
// files: writes create parents and overwrite, read round-trips, stat sees a file
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello modal\n"))
yield* env.files.write("/workspace/hello.txt", new TextEncoder().encode("hello again\n"))
yield* env.files.write("/workspace/nested/deep/hello.txt", new TextEncoder().encode("nested\n"))
expect(
yield* env.files
.read("/workspace/nested/deep/hello.txt")
.pipe(Effect.map((b) => new TextDecoder().decode(b))),
).toBe("nested\n")
const bytes = yield* env.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(bytes)).toBe("hello again\n")
expect((yield* env.files.stat("/workspace/hello.txt")).type).toBe("File")
expect(yield* env.files.realPath("/workspace")).toBe("/workspace")
const listed = yield* env.files.list("/workspace")
expect(listed.map((entry) => entry.name)).toContain("hello.txt")
// missing files are typed NotFound
const missing = yield* env.files.read("/workspace/nope.txt").pipe(Effect.flip)
expect(missing._tag).toBe("WorkspaceEnvironment.NotFoundError")
// exec: bash sees the file, git and rg are provisioned
const handle = yield* env.process.spawn(
ChildProcess.make(
env.shell.executable,
[...env.shell.args("cat hello.txt && git --version && rg --version | head -1")],
{
cwd: env.directory,
stdin: "ignore",
},
),
)
const [output, code] = yield* Effect.all([Stream.mkString(Stream.decodeText(handle.stdout)), handle.exitCode])
expect(Number(code)).toBe(0)
expect(output).toContain("hello again")
expect(output).toContain("git version")
expect(output).toContain("ripgrep")
// reconnect by binding: durable contents survive a fresh connection
const again = yield* driver.connect(created.binding)
const persisted = yield* again.files.read("/workspace/hello.txt")
expect(new TextDecoder().decode(persisted)).toBe("hello again\n")
// destroy, then connect must fail typed
yield* driver.destroy(created.binding)
const dead = yield* driver.connect(created.binding).pipe(Effect.flip)
expect(dead._tag).toBe("WorkspaceDriver.Error")
}).pipe(Effect.scoped, Effect.runPromise)
},
{ timeout: 600_000 },
)
})
+2 -1
View File
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
@@ -584,6 +584,7 @@ function App(props: { pair?: DialogPairCredentials }) {
order: "desc",
parentID: null,
directory: location.directory,
workspace: location.workspaceID,
})
.then((response) => {
const match = response.data[0]?.id
+4 -5
View File
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
labels: ["current directory", "global"],
},
{
title: "Vertical",
title: "Layout",
category: "Tabs",
path: ["tabs", "vertical"],
default: false,
values: [false, true],
labels: ["off", "on"],
path: ["tabs", "layout"],
default: "horizontal",
values: ["horizontal", "vertical"],
keywords: ["sidebar", "orientation", "left"],
},
{
@@ -713,7 +713,7 @@ async function disconnected(
function location(data: ReturnType<typeof useData>) {
const current = data.location.default()
return { directory: current.directory }
return { directory: current.directory, workspace: current.workspaceID }
}
function message(cause: unknown) {
+1 -1
View File
@@ -107,7 +107,7 @@ export function DialogMcp() {
if (!server || server.status.status === "pending") return
setLoading(name)
const current = data.location.default()
const input = { server: name, location: { directory: current.directory } }
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
void call.catch(toast.error).finally(() => setLoading(null))
}
+6 -2
View File
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
const location = useLocation()
const [query, setQuery] = createSignal("")
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)

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