diff --git a/.changeset/current-opencode-dev.md b/.changeset/current-opencode-dev.md new file mode 100644 index 0000000..976630f --- /dev/null +++ b/.changeset/current-opencode-dev.md @@ -0,0 +1,5 @@ +--- +"opencode-drive": patch +--- + +Restore `--dev` compatibility with current OpenCode checkouts by generating the current provider shape and supplying Drive's UI and simulated-model bridges through the TUI plugin host. diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 403af91..74d84c8 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -31,7 +31,7 @@ "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "effect": "4.0.0-beta.98", + "effect": "4.0.0-beta.101", "opencode-drive": "workspace:*", "oxlint": "1.60.0", "typescript": "^7.0.2" diff --git a/bun.lock b/bun.lock index 540da26..8e21658 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "effect": "4.0.0-beta.98", + "effect": "4.0.0-beta.101", "opencode-drive": "workspace:*", "oxlint": "1.60.0", "typescript": "^7.0.2", @@ -31,21 +31,22 @@ }, "packages/drive": { "name": "opencode-drive", - "version": "1.3.0", + "version": "1.4.0", "bin": { "opencode-drive": "bin/opencode-drive", }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.98", - "@effect/platform-node-shared": "4.0.0-beta.98", + "@effect/platform-node": "4.0.0-beta.101", + "@effect/platform-node-shared": "4.0.0-beta.101", "@napi-rs/canvas": "1.0.2", - "@opencode-ai/client": "0.0.0-next-15747", + "@opencode-ai/client": "0.0.0-next-16486", + "@opentui/core": "0.4.5", "@wterm/core": "0.3.0", "@wterm/ghostty": "0.3.0", - "effect": "4.0.0-beta.98", + "effect": "4.0.0-beta.101", }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.98", + "@effect/vitest": "4.0.0-beta.101", "@tsconfig/bun": "1.0.9", "@types/bun": "1.3.13", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -93,11 +94,11 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], - "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-uXRPuN8Y6v43/OVmQwKOd/VFDh+dipaxGKdWPr1bdnM+4bl8NZlYYRJi5omgXLFZ6ZbleduXKcmLM3NeePe69w=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-F5Ur8pZYti0xkZFyb4hPZt8RrKQ1XBoC28ZkKxc7N1iPbhE9fWOvlWA1NC2XlN2wWG08yb5g5jR53LdOxjlhpQ=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -167,14 +168,32 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opencode-ai/client": ["@opencode-ai/client@0.0.0-next-15747", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-15747", "@opencode-ai/schema": "0.0.0-next-15747" }, "peerDependencies": { "effect": "4.0.0-beta.98" }, "optionalPeers": ["effect"] }, "sha512-18j1BXJA34wq52PoP0ddalOWE2Ij9cpemT0wYokqAICcztdj4ojWLeTrFeQldTEzTOj0z1hEZjMB6l8S7UCUFQ=="], + "@opencode-ai/client": ["@opencode-ai/client@0.0.0-next-16486", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-16486", "@opencode-ai/schema": "0.0.0-next-16486" }, "peerDependencies": { "effect": "4.0.0-beta.101" }, "optionalPeers": ["effect"] }, "sha512-v/qCXDCyAJ1utclGm7YmcsXLI1UtCQKK2xgfFr+iwYvHclRZ90R7WtVZOKUJDscX9JpBQ6qFfOSitswzrr14jQ=="], - "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-next-15747", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-15747", "effect": "4.0.0-beta.98" } }, "sha512-VX51qDVA7FoaeAKNSM2hguaLg7W6vSXqh8Q7hM96vKxMbzl3P2+ESuwD11GQn93YuvkBlyPzvyEPeYgI/vhTVA=="], + "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-next-16486", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-16486", "effect": "4.0.0-beta.101" } }, "sha512-qeQc1DVGph/zoQK1pvqADaZiaYRe+lT9TnO7FuTreCguB7FLw0U6M3XGR2FBG88Oe+zsjbDydR9gsV7PInY0jQ=="], - "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-next-15747", "", { "dependencies": { "effect": "4.0.0-beta.98" } }, "sha512-BrXOa6Ym5ivzJ7rc4ztw8GlLVqJkZ6G4cZXaJRAQhsOetoASSL2zspCX/T2GjI0txx40EWCGtH+r9lMgMWbwew=="], + "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-next-16486", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-beta.101" } }, "sha512-ACltRzpA05k+mXBA7Qdtijq/wRz3N9kru3H6Ls3lAarvSg6A+efXM18GOz48VFFD5q7wstz7SNy0l4QUA/J+iw=="], "@opencode-drive/catalog": ["@opencode-drive/catalog@workspace:apps/catalog"], + "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], + + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P20j3MLqfwIT+94qGU3htC7dWp4pXGZW1p1p7FRUzu1aopq7c9nPCgf0W/WjktqQ57+iuTq9mbSlwWinl6+H1A=="], @@ -357,7 +376,7 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -369,6 +388,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -391,9 +412,13 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="], + "effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -427,6 +452,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -491,6 +518,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -609,7 +638,9 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -643,6 +674,8 @@ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -659,6 +692,10 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "bun-ffi-structs/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "opencode-drive/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], @@ -667,6 +704,8 @@ "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "opencode-drive/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], diff --git a/packages/drive/package.json b/packages/drive/package.json index d535df2..250c32f 100644 --- a/packages/drive/package.json +++ b/packages/drive/package.json @@ -58,16 +58,17 @@ "release:validate": "bun run check && bun run test && bun pm pack --dry-run" }, "dependencies": { - "@effect/platform-node": "4.0.0-beta.98", - "@effect/platform-node-shared": "4.0.0-beta.98", + "@effect/platform-node": "4.0.0-beta.101", + "@effect/platform-node-shared": "4.0.0-beta.101", "@napi-rs/canvas": "1.0.2", - "@opencode-ai/client": "0.0.0-next-15747", + "@opencode-ai/client": "0.0.0-next-16486", + "@opentui/core": "0.4.5", "@wterm/core": "0.3.0", "@wterm/ghostty": "0.3.0", - "effect": "4.0.0-beta.98" + "effect": "4.0.0-beta.101" }, "devDependencies": { - "@effect/vitest": "4.0.0-beta.98", + "@effect/vitest": "4.0.0-beta.101", "@tsconfig/bun": "1.0.9", "@types/bun": "1.3.13", "@typescript/native-preview": "7.0.0-dev.20251207.1", diff --git a/packages/drive/src/cli/run.ts b/packages/drive/src/cli/run.ts index a8f47d3..cd215a2 100644 --- a/packages/drive/src/cli/run.ts +++ b/packages/drive/src/cli/run.ts @@ -16,15 +16,17 @@ export const runProgram = Effect.fn("Cli.runProgram")((file: string) => }), ({ file }) => Effect.gen(function* () { - const module = yield* Effect.tryPromise({ - try: () => import(pathToFileURL(file).href), + const program = yield* Effect.tryPromise({ + // prepareProgram type-checks this boundary as a fully provided Effect. + try: async (): Promise> => { + const module = await import(pathToFileURL(file).href) + if (!Effect.isEffect(module.default)) + throw new Error("program must default-export a fully provided Effect") + return module.default + }, catch: (cause) => cause, }) - if (!Effect.isEffect(module.default)) - return yield* Effect.fail( - new Error("program must default-export a fully provided Effect"), - ) - return yield* module.default + return yield* program }), ({ remove }) => Effect.promise(remove), ), diff --git a/packages/drive/src/instance/default-config.jsonc b/packages/drive/src/instance/default-config.jsonc index 809b4a1..717e3ce 100644 --- a/packages/drive/src/instance/default-config.jsonc +++ b/packages/drive/src/instance/default-config.jsonc @@ -11,9 +11,11 @@ "providers": { "simulation": { "name": "Simulation", - "package": "aisdk:@ai-sdk/openai-compatible", - "settings": { - "baseURL": "https://api.openai.com/v1" + "api": { + "type": "aisdk", + "package": "@ai-sdk/openai-compatible", + "url": "https://api.openai.com/v1", + "settings": {} }, "request": { "body": { diff --git a/packages/drive/src/instance/dev-integration.ts b/packages/drive/src/instance/dev-integration.ts new file mode 100644 index 0000000..86f8cf9 --- /dev/null +++ b/packages/drive/src/instance/dev-integration.ts @@ -0,0 +1,514 @@ +import { appendFile, mkdir } from "node:fs/promises" +import { extname, join } from "node:path" +import type { CliRenderer, Renderable } from "@opentui/core" +import { createMockKeys, createMockMouse, KeyCodes } from "@opentui/core/testing" +import { renderFrame } from "../recording/render.js" + +interface Manifest { + readonly endpoints: { readonly ui: string; readonly backend: string } + readonly viewport?: { readonly cols: number; readonly rows: number } + readonly recording?: { readonly timeline: string } +} + +interface RpcRequest { + readonly jsonrpc?: string + readonly id?: string | number + readonly method?: string + readonly params?: Record +} + +interface Exchange { + readonly id: string + readonly body: unknown + readonly stream: ReadableStreamDefaultController +} + +interface PluginInput { + readonly api: { readonly renderer: CliRenderer } +} + +interface ControlSocket { + send(message: string): unknown +} + +const encoder = new TextEncoder() +const frontendCapabilities = [ + "ui.type", + "ui.press", + "ui.enter", + "ui.arrow", + "ui.focus", + "ui.click", + "ui.resize", + "ui.matches", + "ui.screenshot", + "ui.state", + "ui.capture", + "ui.recording.finish", +] +const backendCapabilities = [ + "llm.attach", + "llm.chunk", + "llm.finish", + "llm.disconnect", + "llm.pending", + "llm.request", + "llm.tool-input-delta", +] + +export function createDrivePluginHost() { + const servers: Bun.Server<{ readonly role: "ui" | "backend" }>[] = [] + return { + start(input: PluginInput) { + void resolveManifest().then((manifest) => { + if (manifest.viewport) input.api.renderer.resize(manifest.viewport.cols, manifest.viewport.rows) + servers.push(startUi(input.api.renderer, manifest), startDevBackend(manifest)) + }) + return Promise.resolve() + }, + async dispose() { + await Promise.all(servers.splice(0).map((server) => server.stop(true))) + }, + } +} + +export async function waitForDriveProvider(transport: { + readonly url: string + readonly headers: RequestInit["headers"] +}) { + const url = new URL("/api/provider", transport.url) + url.searchParams.set("location[directory]", process.cwd()) + const started = performance.now() + while (performance.now() - started < 10_000) { + const response = await fetch(url, { headers: transport.headers }) + if (response.ok) { + const value: unknown = await response.json() + if ( + isRecord(value) && + Array.isArray(value.data) && + value.data.some((provider) => isRecord(provider) && provider.id === "simulation") + ) return + } + await Bun.sleep(20) + } + throw new Error("Timed out waiting for the Drive simulation provider") +} + +export function driveLegacyFallback(pathname: string) { + const provider = { + id: "simulation", + name: "Simulation", + source: "config", + env: [], + options: {}, + models: { + "gpt-sim-model": { + id: "gpt-sim-model", + providerID: "simulation", + api: { + id: "gpt-sim-model", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + name: "Simulated Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 16_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "1970-01-01", + }, + }, + } + if (pathname === "/config/providers") + return { + providers: [provider], + default: { simulation: "gpt-sim-model" }, + } + if (pathname === "/provider") + return { + all: [provider], + default: { simulation: "gpt-sim-model" }, + connected: ["simulation"], + } + return undefined +} + +function startUi(renderer: CliRenderer, manifest: Manifest) { + const input = createMockKeys(renderer) + const mouse = createMockMouse(renderer) + const endpoint = new URL(manifest.endpoints.ui) + const started = performance.now() + const render = async () => { + renderer.requestRender() + await renderer.idle() + } + const record = async () => { + if (!manifest.recording) return + await appendFile( + manifest.recording.timeline, + `${JSON.stringify({ + type: "output", + at_ms: Math.max(0, Math.round(performance.now() - started)), + data: Buffer.from(screen(renderer)).toString("base64"), + })}\n`, + ) + } + if (manifest.recording) + void Bun.write( + manifest.recording.timeline, + `${JSON.stringify({ + type: "header", + version: 1, + cols: manifest.viewport?.cols ?? 80, + rows: manifest.viewport?.rows ?? 24, + encoding: "base64", + })}\n`, + ) + + return Bun.serve<{ readonly role: "ui" }>({ + hostname: endpoint.hostname, + port: Number(endpoint.port), + fetch(request, server) { + if (server.upgrade(request, { data: { role: "ui" } })) return undefined + return new Response("Drive UI WebSocket", { status: 426 }) + }, + websocket: { + async message(socket, message) { + const request = decodeRequest(message) + if (!request) return sendError(socket, undefined, -32600, "Invalid JSON-RPC request") + try { + const result = await handleUi(request, renderer, input, mouse, manifest, render, record) + sendResult(socket, request.id, result) + } catch (cause) { + sendError(socket, request.id, -32000, cause instanceof Error ? cause.message : String(cause)) + } + }, + }, + }) +} + +export function startDevBackend(manifest: Manifest) { + const endpoint = new URL(manifest.endpoints.backend) + const pending = new Map() + let controller: ControlSocket | undefined + let counter = 0 + + const notify = (exchange: Exchange) => + controller?.send(JSON.stringify({ + jsonrpc: "2.0", + method: "llm.request", + params: { + id: exchange.id, + url: "https://api.openai.com/v1/chat/completions", + body: exchange.body, + }, + })) + + return Bun.serve<{ readonly role: "backend" }>({ + hostname: endpoint.hostname, + port: Number(endpoint.port), + idleTimeout: 255, + async fetch(request, server) { + if (server.upgrade(request, { data: { role: "backend" } })) return undefined + const url = new URL(request.url) + if (request.method !== "POST" || !url.pathname.endsWith("/chat/completions")) + return Response.json({ error: "Not found" }, { status: 404 }) + const body: unknown = await request.json() + const id = `ex_${++counter}` + const stream = new ReadableStream({ + start(stream) { + const exchange = { id, body, stream } + pending.set(id, exchange) + notify(exchange) + }, + cancel() { + pending.delete(id) + }, + }) + return new Response(stream, { + headers: { + "cache-control": "no-cache", + "content-type": "text/event-stream", + connection: "keep-alive", + }, + }) + }, + websocket: { + message(socket, message) { + const request = decodeRequest(message) + if (!request) return sendError(socket, undefined, -32600, "Invalid JSON-RPC request") + if (request.method === "simulation.handshake") + return sendResult(socket, request.id, handshake("backend", backendCapabilities)) + if (request.method === "llm.attach") { + controller = socket + pending.forEach(notify) + return sendResult(socket, request.id, { attached: true }) + } + if (request.method === "llm.pending") + return sendResult(socket, request.id, { + invocations: [...pending.values()].map((item) => ({ id: item.id, url: "https://api.openai.com/v1/chat/completions", body: item.body })), + }) + const id = typeof request.params?.id === "string" ? request.params.id : undefined + const exchange = id === undefined ? undefined : pending.get(id) + if (!exchange) return sendError(socket, request.id, -32000, `Simulated provider request not found: ${id}`) + if (request.method === "llm.chunk") { + const items = Array.isArray(request.params?.items) ? request.params.items : [] + items.forEach((item) => exchange.stream.enqueue(sseChunk(providerChunk(item)))) + return sendResult(socket, request.id, { ok: true }) + } + if (request.method === "llm.finish") { + exchange.stream.enqueue(sseChunk({ + choices: [{ delta: {}, finish_reason: finishReason(request.params?.reason) }], + })) + exchange.stream.enqueue(encoder.encode("data: [DONE]\n\n")) + exchange.stream.close() + pending.delete(exchange.id) + return sendResult(socket, request.id, { ok: true }) + } + if (request.method === "llm.disconnect") { + exchange.stream.error(new Error("simulated provider disconnected")) + pending.delete(exchange.id) + return sendResult(socket, request.id, { ok: true }) + } + return sendError(socket, request.id, -32601, `Method not found: ${request.method}`) + }, + close(socket) { + if (controller !== socket) return + controller = undefined + }, + }, + }) +} + +async function handleUi( + request: RpcRequest, + renderer: CliRenderer, + input: ReturnType, + mouse: ReturnType, + manifest: Manifest, + render: () => Promise, + record: () => Promise, +) { + if (request.method === "simulation.handshake") return handshake("ui", frontendCapabilities) + if (request.method === "ui.capture") { + await render() + return capture(renderer) + } + if (request.method === "ui.matches") + return screen(renderer).includes(typeof request.params?.text === "string" ? request.params.text : "") + if (request.method === "ui.state") return state(renderer) + if (request.method === "ui.snapshot") return { format: "opencode-ui-snapshot-v1", nodes: [] } + if (request.method === "ui.screenshot") { + const name = typeof request.params?.name === "string" ? request.params.name : `screenshot-${crypto.randomUUID()}` + if (!name || name.includes("/") || name.includes("\\") || extname(name)) + throw new Error("screenshot name must not contain a path or extension") + await render() + const directory = process.env.OPENCODE_DRIVE_MEDIA_DIR ?? join(Bun.env.TMPDIR ?? "/tmp", "opencode-drive", "output") + await mkdir(directory, { recursive: true }) + const path = join(directory, `${name}.png`) + await Bun.write(path, renderFrame(toRecordingFrame(capture(renderer)))) + return path + } + if (request.method === "ui.recording.finish") { + if (!manifest.recording) throw new Error("UI recording is not available") + await record() + return manifest.recording.timeline + } + if (request.method === "ui.type") + await input.typeText(typeof request.params?.text === "string" ? request.params.text : "") + if (request.method === "ui.enter") input.pressEnter() + if (request.method === "ui.arrow" && isDirection(request.params?.direction)) input.pressArrow(request.params.direction) + if (request.method === "ui.press") { + const key = typeof request.params?.key === "string" ? request.params.key : "" + const named = key.toUpperCase() + input.pressKey( + isKeyCode(named) ? named : key, + isModifiers(request.params?.modifiers) ? request.params.modifiers : undefined, + ) + } + if (request.method === "ui.focus") all(renderer.root).find((item) => item.num === request.params?.target)?.focus() + if (request.method === "ui.click") { + const target = all(renderer.root).find((item) => item.num === request.params?.target) + if (!target || !target.visible || target.isDestroyed) + throw new Error(`click target is stale or unavailable: ${String(request.params?.target)}`) + await mouse.click(target.screenX + Number(request.params?.x ?? 0), target.screenY + Number(request.params?.y ?? 0)) + } + if (request.method === "ui.resize") renderer.resize(Number(request.params?.cols), Number(request.params?.rows)) + await render() + await record() + return state(renderer) +} + +function capture(renderer: CliRenderer) { + const buffer = renderer.currentRenderBuffer + return { + cols: buffer.width, + rows: buffer.height, + cursor: [0, 0] as const, + lines: buffer.getSpanLines().map((line) => ({ + spans: line.spans.map((span) => ({ + text: span.text, + fg: span.fg.toInts(), + bg: span.bg.toInts(), + attributes: span.attributes, + width: span.width, + })), + })), + } +} + +function state(renderer: CliRenderer) { + return { + focused: { + renderable: renderer.currentFocusedRenderable?.num, + editor: Boolean(renderer.currentFocusedEditor), + }, + elements: all(renderer.root) + .filter((item) => item.visible && !item.isDestroyed) + .map((item) => ({ + id: item.id, + num: item.num, + x: item.screenX, + y: item.screenY, + width: item.width, + height: item.height, + focusable: item.focusable, + focused: item.focused, + clickable: hasMouseListener(item), + editor: renderer.currentFocusedEditor === item, + })) + .filter((item) => item.focusable || item.clickable || item.editor), + } +} + +function providerChunk(value: unknown) { + if (!isRecord(value) || typeof value.type !== "string") return value + if (value.type === "raw") return value.chunk + if (value.type === "textDelta") return { choices: [{ delta: { content: value.text } }] } + if (value.type === "reasoningDelta") return { choices: [{ delta: { reasoning_content: value.text } }] } + if (value.type === "toolInputStart") + return { choices: [{ delta: { tool_calls: [{ index: value.index, id: value.id, function: { name: value.name, arguments: "" } }] } }] } + if (value.type === "toolInputDelta") + return { choices: [{ delta: { tool_calls: [{ index: value.index, function: { arguments: value.text } }] } }] } + if (value.type === "toolCall") + return { choices: [{ delta: { tool_calls: [{ index: value.index, id: value.id, function: { name: value.name, arguments: JSON.stringify(value.input) } }] } }] } + return value +} + +function finishReason(reason: unknown) { + if (reason === "tool-calls") return "tool_calls" + if (reason === "content-filter") return "content_filter" + return reason === "length" ? "length" : "stop" +} + +function handshake(role: "ui" | "backend", capabilities: readonly string[]) { + return { + protocolVersion: 1, + role, + server: { name: "opencode", version: "dev" }, + capabilities, + } +} + +function decodeRequest(message: string | Buffer) { + try { + const value: unknown = JSON.parse(String(message)) + return isRecord(value) && value.jsonrpc === "2.0" && typeof value.method === "string" ? value as RpcRequest : undefined + } catch { + return undefined + } +} + +function sendResult(socket: ControlSocket, id: RpcRequest["id"], result: unknown) { + if (id === undefined) return + socket.send(JSON.stringify({ jsonrpc: "2.0", id, result })) +} + +function sendError(socket: ControlSocket, id: RpcRequest["id"], code: number, message: string) { + if (id === undefined) return + socket.send(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })) +} + +function sseChunk(value: unknown) { + return encoder.encode(`data: ${JSON.stringify(value)}\n\n`) +} + +function all(renderable: Renderable): Renderable[] { + const children = renderable.getChildren().filter((child): child is Renderable => "num" in child) + return [renderable, ...children.flatMap(all)] +} + +function hasMouseListener(renderable: Renderable) { + const listener = Reflect.get(renderable, "_mouseListener") + const listeners = Reflect.get(renderable, "_mouseListeners") + return Boolean(listener) || (isRecord(listeners) && Object.keys(listeners).length > 0) +} + +function screen(renderer: CliRenderer) { + return new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes()) +} + +function toRecordingFrame(frame: ReturnType) { + return { + cols: frame.cols, + rows: frame.rows, + cursor: { row: 0, col: 0, visible: false }, + lines: frame.lines.map((line) => ({ + spans: line.spans.map((span) => ({ + ...span, + fg: rgb(span.fg), + bg: rgb(span.bg), + })), + })), + } +} + +function rgb(value: readonly number[]) { + return ((value[0] ?? 0) << 16) | ((value[1] ?? 0) << 8) | (value[2] ?? 0) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isModifiers(value: unknown): value is { + readonly shift?: boolean + readonly ctrl?: boolean + readonly meta?: boolean + readonly super?: boolean + readonly hyper?: boolean +} { + return isRecord(value) && Object.values(value).every((item) => typeof item === "boolean") +} + +function isDirection(value: unknown): value is "up" | "down" | "left" | "right" { + return value === "up" || value === "down" || value === "left" || value === "right" +} + +function isKeyCode(value: string): value is keyof typeof KeyCodes { + return Object.hasOwn(KeyCodes, value) +} + +async function resolveManifest() { + const name = process.env.OPENCODE_DRIVE + const directory = process.env.DRIVE_REGISTRY_DIR + if (!name || !directory) throw new Error("Drive manifest environment is unavailable") + const value: unknown = await Bun.file(join(directory, `${name}.json`)).json() + if (!isManifest(value)) throw new Error("Drive manifest is invalid") + return value +} + +function isManifest(value: unknown): value is Manifest { + if (!isRecord(value) || !isRecord(value.endpoints)) return false + return typeof value.endpoints.ui === "string" && typeof value.endpoints.backend === "string" +} diff --git a/packages/drive/src/instance/dev-preload.ts b/packages/drive/src/instance/dev-preload.ts new file mode 100644 index 0000000..78a60a9 --- /dev/null +++ b/packages/drive/src/instance/dev-preload.ts @@ -0,0 +1,46 @@ +import { resolve } from "node:path" + +const dev = process.env.OPENCODE_DRIVE_DEV + +if (dev !== undefined) { + const cliTui = resolve(dev, "packages", "cli", "src", "tui.ts") + const integration = new URL("./dev-integration.ts", import.meta.url).href + + Bun.plugin({ + name: "opencode-drive-dev", + setup(build) { + build.onLoad({ filter: /\/packages\/cli\/src\/tui\.ts$/ }, async (args) => { + if (resolve(args.path) !== cliTui) return undefined + const source = await Bun.file(args.path).text() + const target = `pluginHost: { + async start() {}, + async dispose() {}, + },` + if (!source.includes(target)) throw new Error("Current OpenCode TUI plugin host seam was not found") + const withIntegration = source.replace( + 'import { run } from "@opencode-ai/tui"', + `import { run } from "@opencode-ai/tui"\nimport { createDrivePluginHost, driveLegacyFallback, waitForDriveProvider } from ${JSON.stringify(integration)}`, + ).replace(target, "pluginHost: createDrivePluginHost(),") + const withFallback = withIntegration.replace( + "const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]", + "const pathname = new URL(input instanceof Request ? input.url : input).pathname\n const fallback = driveLegacyFallback(pathname) ?? legacyDefaults[pathname]", + ) + if (withFallback === withIntegration) throw new Error("Current OpenCode legacy provider fallback seam was not found") + const delayed = withFallback + .replace( + " return run({", + " return Effect.promise(() => waitForDriveProvider(transport)).pipe(Effect.andThen(run({", + ) + .replace( + " }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))\n}", + " }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))))\n}", + ) + if (delayed === withFallback) throw new Error("Current OpenCode TUI startup seam was not found") + return { + loader: "ts", + contents: delayed, + } + }) + }, + }) +} diff --git a/packages/drive/src/instance/dev.ts b/packages/drive/src/instance/dev.ts index ce5f2e8..5ca7618 100644 --- a/packages/drive/src/instance/dev.ts +++ b/packages/drive/src/instance/dev.ts @@ -14,7 +14,7 @@ export const prepareDev = Effect.fn("OpenCodeInstance.prepareDev")(function* ( const root = resolve(directory) const entrypoint = join(root, "packages", "cli", "src", "index.ts") const solid = join(root, "packages", "tui", "node_modules", "@opentui", "solid") - yield* Effect.tryPromise({ + const standalone = yield* Effect.tryPromise({ try: async () => { if (!(await Bun.file(entrypoint).exists())) throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`) @@ -26,13 +26,17 @@ export const prepareDev = Effect.fn("OpenCodeInstance.prepareDev")(function* ( }) await rm(preload, { recursive: true, force: true }) await symlink(solid, preload, "dir") + return Bun.file(join(root, "packages", "cli", "src", "services", "standalone.ts")).exists() }, catch: (cause) => instanceError("prepare development checkout", cause), }) - return [ - process.execPath, + const preloads = [ "--conditions=browser", - "--preload=@opentui/solid/preload", - entrypoint, + `--preload=${join(solid, "scripts", "preload.js")}`, + `--preload=${new URL("./dev-preload.ts", import.meta.url).pathname}`, ] + return { + command: [process.execPath, ...preloads, entrypoint, ...(standalone ? ["--standalone"] : [])], + preloads, + } }) diff --git a/packages/drive/src/instance/instance.ts b/packages/drive/src/instance/instance.ts index 77a5978..42f0dfa 100644 --- a/packages/drive/src/instance/instance.ts +++ b/packages/drive/src/instance/instance.ts @@ -75,7 +75,7 @@ export const prepareInstanceProject = Effect.fn("OpenCodeInstance.prepareProject if (options.setup !== undefined) { const protectGit = Boolean(options.project?.git) || (yield* promise(() => hasGitMetadata(files))) - const setup: unknown = options.setup({ + const setup: Effect.Effect = options.setup({ fs: createScriptFileSystem(files, { git: protectGit }), config, tuiConfig: tui, @@ -93,6 +93,22 @@ export const prepareInstanceProject = Effect.fn("OpenCodeInstance.prepareProject return undefined }) +export async function configureDevSimulation(artifacts: string, endpoint: string) { + const path = join(resolve(artifacts), "files", ".opencode", "opencode.jsonc") + const config = await readConfig(path, "opencode.jsonc") + const providers = config.providers + if (!isJsonObject(providers)) return + const simulation = providers.simulation + if (!isJsonObject(simulation)) return + const api = simulation.api + if (!isJsonObject(api) || api.package !== "@ai-sdk/openai-compatible") return + const url = new URL(endpoint) + url.protocol = "http:" + url.pathname = "/v1" + deepMerge(api, { url: url.toString().replace(/\/$/, "") }) + await Bun.write(path, `${JSON.stringify(config, undefined, 2)}\n`) +} + const promise = (evaluate: () => PromiseLike) => Effect.tryPromise({ try: evaluate, diff --git a/packages/drive/src/instance/runtime.ts b/packages/drive/src/instance/runtime.ts index dc66a45..2d21065 100644 --- a/packages/drive/src/instance/runtime.ts +++ b/packages/drive/src/instance/runtime.ts @@ -11,7 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process" import { prepareDev } from "./dev.js" import { instanceError, OpenCodeInstanceError } from "./error.js" import { runMediaDirectory } from "./media.js" -import { prepareInstanceProject } from "./instance.js" +import { configureDevSimulation, prepareInstanceProject } from "./instance.js" import * as Process from "./process.js" import { freePort, waitForWebSocket } from "./readiness.js" import { isValidName } from "./registry.js" @@ -140,13 +140,25 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( }).pipe( Effect.mapError((cause) => instanceError("prepare project", cause)), ) + if (options.dev !== undefined) + yield* Effect.tryPromise({ + try: () => configureDevSimulation(artifacts, endpoints.backend), + catch: (cause) => instanceError("configure development simulation", cause), + }) + const dev = options.dev !== undefined + ? yield* prepareDev(artifacts, options.dev) + : undefined const environment = stripGitEnvironment({ ...process.env, ...options.env, + BUN_OPTIONS: dev === undefined + ? options.env?.BUN_OPTIONS ?? process.env.BUN_OPTIONS + : [options.env?.BUN_OPTIONS ?? process.env.BUN_OPTIONS, ...dev.preloads].filter(Boolean).join(" "), OPENCODE_SIMULATE: "1", OPENCODE_DRIVE_SCRIPTED: options.scripted ? "1" : undefined, DRIVE_REGISTRY_DIR: drive, OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless", + OPENCODE_DRIVE_DEV: options.dev, OPENCODE_CONFIG_DIR: join(files, ".opencode"), OPENCODE_DB: database, OPENCODE_LOG_LEVEL: !options.visible ? "DEBUG" : process.env.OPENCODE_LOG_LEVEL, @@ -156,8 +168,8 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( XDG_DATA_HOME: logs, XDG_STATE_HOME: join(artifacts, "home", ".local", "state"), }) - const command = options.dev !== undefined - ? yield* prepareDev(artifacts, options.dev) + const command = dev !== undefined + ? dev.command : options.command?.length ? [...options.command] : ["opencode2"] diff --git a/packages/drive/test/cli/integration.test.ts b/packages/drive/test/cli/integration.test.ts index 366afca..2f5cd1f 100644 --- a/packages/drive/test/cli/integration.test.ts +++ b/packages/drive/test/cli/integration.test.ts @@ -121,7 +121,11 @@ describe("opencode-drive", () => { permissions: [{ action: "*", resource: "*", effect: "allow" }], providers: { simulation: { - package: "aisdk:@ai-sdk/openai-compatible", + api: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.openai.com/v1", + }, models: { "gpt-sim-model": { capabilities: { tools: true } } }, }, }, diff --git a/packages/drive/test/instance/config.test.ts b/packages/drive/test/instance/config.test.ts index b8b9881..7b51c79 100644 --- a/packages/drive/test/instance/config.test.ts +++ b/packages/drive/test/instance/config.test.ts @@ -3,6 +3,7 @@ import { rm } from "node:fs/promises" import { join } from "node:path" import * as Effect from "effect/Effect" import { + configureDevSimulation, initializeInstance, prepareInstanceProject, } from "../../src/instance/instance.js" @@ -18,6 +19,41 @@ afterEach(async () => { }) describe("instance configuration", () => { + test("initializes the simulation provider with the current OpenCode provider shape", async () => { + const root = await initializeInstance() + artifacts.push(root) + + expect(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({ + model: "simulation/gpt-sim-model", + providers: { + simulation: { + api: { + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.openai.com/v1", + settings: {}, + }, + request: { body: { apiKey: "sim-key" } }, + }, + }, + }) + }) + + test("points the development provider at the isolated simulation endpoint", async () => { + const root = await initializeInstance() + artifacts.push(root) + + await configureDevSimulation(root, "ws://127.0.0.1:43123") + + expect(await Bun.file(join(root, "files", ".opencode", "opencode.jsonc")).json()).toMatchObject({ + providers: { + simulation: { + api: { url: "http://127.0.0.1:43123/v1" }, + }, + }, + }) + }) + test("merges JSONC fixtures, replaces arrays, applies setup last, and commits normalized files", async () => { const root = await initializeInstance() artifacts.push(root) diff --git a/packages/drive/test/instance/dev-integration.test.ts b/packages/drive/test/instance/dev-integration.test.ts new file mode 100644 index 0000000..4a173d8 --- /dev/null +++ b/packages/drive/test/instance/dev-integration.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, test } from "vitest" +import { + driveLegacyFallback, + startDevBackend, + waitForDriveProvider, +} from "../../src/instance/dev-integration.js" + +const servers: Bun.Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop(true))) +}) + +test("bridges OpenAI-compatible requests through the Drive backend protocol", async () => { + const server = startDevBackend({ + endpoints: { + ui: "ws://127.0.0.1:1", + backend: "ws://127.0.0.1:0", + }, + }) + servers.push(server) + const socket = new WebSocket(`ws://127.0.0.1:${server.port}`) + const messages: unknown[] = [] + socket.addEventListener("message", (event) => messages.push(JSON.parse(String(event.data)))) + await new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }) + socket.addEventListener("error", () => reject(new Error("backend WebSocket failed to open")), { once: true }) + }) + + socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" })) + await waitFor(() => messages.some((message) => isRecord(message) && message.id === 1)) + + const response = fetch(`http://127.0.0.1:${server.port}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-sim-model", stream: true, messages: [] }), + }) + const request = await waitFor(() => + messages.find((message) => + isRecord(message) && message.method === "llm.request" + ) as { readonly params?: { readonly id?: string } } | undefined) + const id = request.params?.id + if (typeof id !== "string") throw new Error("backend request did not include an ID") + socket.send(JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "llm.chunk", + params: { id, items: [{ type: "textDelta", text: "Current OpenCode works." }] }, + })) + socket.send(JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "llm.finish", + params: { id, reason: "stop" }, + })) + + expect(await (await response).text()).toContain('"content":"Current OpenCode works."') + socket.close() +}) + +test("waits through the current OpenCode provider startup race", async () => { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch() { + requests++ + return Response.json({ + data: requests < 3 ? [] : [{ id: "simulation" }], + }) + }, + }) + servers.push(server) + + await waitForDriveProvider({ url: `http://127.0.0.1:${server.port}`, headers: {} }) + + expect(requests).toBe(3) +}) + +test("fills the current TUI legacy bootstrap from the simulation provider", () => { + expect(driveLegacyFallback("/config/providers")).toMatchObject({ + providers: [{ id: "simulation", models: { "gpt-sim-model": { capabilities: { interleaved: false } } } }], + default: { simulation: "gpt-sim-model" }, + }) + expect(driveLegacyFallback("/provider")).toMatchObject({ + all: [{ id: "simulation", models: { "gpt-sim-model": { capabilities: { toolcall: true } } } }], + connected: ["simulation"], + }) +}) + +async function waitFor(evaluate: () => A | undefined | false, timeout = 2_000): Promise { + const started = performance.now() + while (performance.now() - started < timeout) { + const value = evaluate() + if (value !== undefined && value !== false) return value + await Bun.sleep(5) + } + throw new Error("timed out waiting for condition") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/drive/test/instance/dev.test.ts b/packages/drive/test/instance/dev.test.ts new file mode 100644 index 0000000..733e50a --- /dev/null +++ b/packages/drive/test/instance/dev.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, test } from "vitest" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import * as Effect from "effect/Effect" +import { prepareDev } from "../../src/instance/dev.js" + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +test("uses standalone mode and inheritable preloads for V2 development checkouts", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-drive-dev-")) + const artifacts = await mkdtemp(join(tmpdir(), "opencode-drive-artifacts-")) + directories.push(root, artifacts) + await mkdir(join(root, "packages", "cli", "src", "services"), { recursive: true }) + await mkdir(join(root, "packages", "tui", "node_modules", "@opentui", "solid"), { recursive: true }) + await Promise.all([ + Bun.write(join(root, "packages", "cli", "src", "index.ts"), ""), + Bun.write(join(root, "packages", "cli", "src", "services", "standalone.ts"), ""), + Bun.write(join(root, "packages", "tui", "node_modules", "@opentui", "solid", "package.json"), "{}"), + ]) + + const result = await Effect.runPromise(prepareDev(artifacts, root)) + + expect(result.command.at(-1)).toBe("--standalone") + expect(result.preloads).toContain("--conditions=browser") + expect(result.preloads).toContain( + `--preload=${join(root, "packages", "tui", "node_modules", "@opentui", "solid", "scripts", "preload.js")}`, + ) +})