From 5a80c071b0eb4239ad86fe9d3907b0665d7b2788 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 17:52:57 -0400 Subject: [PATCH 1/3] fix(drive): support current OpenCode dev checkouts --- .changeset/current-opencode-dev.md | 5 + apps/catalog/package.json | 2 +- bun.lock | 71 ++- packages/drive/package.json | 11 +- packages/drive/src/cli/run.ts | 16 +- .../drive/src/instance/default-config.jsonc | 8 +- .../drive/src/instance/dev-integration.ts | 514 ++++++++++++++++++ packages/drive/src/instance/dev-preload.ts | 46 ++ packages/drive/src/instance/dev.ts | 1 + packages/drive/src/instance/instance.ts | 18 +- packages/drive/src/instance/runtime.ts | 8 +- packages/drive/test/cli/integration.test.ts | 6 +- packages/drive/test/instance/config.test.ts | 36 ++ .../test/instance/dev-integration.test.ts | 102 ++++ 14 files changed, 809 insertions(+), 35 deletions(-) create mode 100644 .changeset/current-opencode-dev.md create mode 100644 packages/drive/src/instance/dev-integration.ts create mode 100644 packages/drive/src/instance/dev-preload.ts create mode 100644 packages/drive/test/instance/dev-integration.test.ts 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..af1ae8b 100644 --- a/packages/drive/src/instance/dev.ts +++ b/packages/drive/src/instance/dev.ts @@ -33,6 +33,7 @@ export const prepareDev = Effect.fn("OpenCodeInstance.prepareDev")(function* ( process.execPath, "--conditions=browser", "--preload=@opentui/solid/preload", + `--preload=${new URL("./dev-preload.ts", import.meta.url).pathname}`, entrypoint, ] }) 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..24cdbc2 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,6 +140,11 @@ 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 environment = stripGitEnvironment({ ...process.env, ...options.env, @@ -147,6 +152,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( 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, 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) +} From 877eeddcebacf9e1c3f95eac2b03f10f9485e7ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 18:04:13 -0400 Subject: [PATCH 2/3] fix(drive): support v2 standalone checkouts --- packages/drive/src/instance/dev.ts | 13 ++++++---- packages/drive/src/instance/runtime.ts | 10 +++++-- packages/drive/test/instance/dev.test.ts | 33 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 packages/drive/test/instance/dev.test.ts diff --git a/packages/drive/src/instance/dev.ts b/packages/drive/src/instance/dev.ts index af1ae8b..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,14 +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", + `--preload=${join(solid, "scripts", "preload.js")}`, `--preload=${new URL("./dev-preload.ts", import.meta.url).pathname}`, - entrypoint, ] + return { + command: [process.execPath, ...preloads, entrypoint, ...(standalone ? ["--standalone"] : [])], + preloads, + } }) diff --git a/packages/drive/src/instance/runtime.ts b/packages/drive/src/instance/runtime.ts index 24cdbc2..2d21065 100644 --- a/packages/drive/src/instance/runtime.ts +++ b/packages/drive/src/instance/runtime.ts @@ -145,9 +145,15 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( 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, @@ -162,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/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")}`, + ) +}) From 7fdf6ab9cd28bce20d1dc6385505292de61e5918 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 15:49:35 -0400 Subject: [PATCH 3/3] fix(drive): support current OpenCode V2 --- .changeset/current-opencode-dev.md | 2 +- bun.lock | 12 +- packages/drive/README.md | 22 +- packages/drive/package.json | 6 +- packages/drive/src/cli/index.ts | 3 +- packages/drive/src/cli/run.ts | 65 +-- packages/drive/src/cli/script-init.ts | 3 +- packages/drive/src/cli/start.ts | 32 +- packages/drive/src/driver/index.ts | 2 +- packages/drive/src/driver/opencode.ts | 2 +- packages/drive/src/index.ts | 1 + .../drive/src/instance/default-config.jsonc | 13 +- .../drive/src/instance/dev-integration.ts | 514 ------------------ packages/drive/src/instance/dev-preload.ts | 46 -- packages/drive/src/instance/dev.ts | 5 +- packages/drive/src/instance/instance.ts | 16 - packages/drive/src/instance/runtime.ts | 19 +- packages/drive/src/recording/ffmpeg.ts | 2 +- packages/drive/src/script/project.ts | 2 +- packages/drive/src/script/tooling.ts | 341 +++++------- packages/drive/test/cli/integration.test.ts | 27 +- packages/drive/test/cli/run.test.ts | 3 +- .../test/fixtures/caught-timeout-script.ts | 2 +- .../drive/test/fixtures/crashing-script.ts | 2 +- .../fixtures/failed-launch-retry-script.ts | 2 +- packages/drive/test/fixtures/fake-opencode.ts | 2 +- .../drive/test/fixtures/hanging-script.ts | 2 +- .../drive/test/fixtures/kill-server-script.ts | 2 +- .../test/fixtures/manual-clients-script.ts | 2 +- .../test/fixtures/prepared-git-script.ts | 2 +- .../drive/test/fixtures/restart-script.ts | 2 +- packages/drive/test/fixtures/script.ts | 2 +- packages/drive/test/fixtures/serve-script.ts | 2 +- packages/drive/test/fixtures/title-script.ts | 2 +- .../drive/test/fixtures/tui-options-script.ts | 2 +- packages/drive/test/instance/config.test.ts | 25 +- .../test/instance/dev-integration.test.ts | 102 ---- packages/drive/test/instance/dev.test.ts | 1 + 38 files changed, 232 insertions(+), 1060 deletions(-) delete mode 100644 packages/drive/src/instance/dev-integration.ts delete mode 100644 packages/drive/src/instance/dev-preload.ts delete mode 100644 packages/drive/test/instance/dev-integration.test.ts diff --git a/.changeset/current-opencode-dev.md b/.changeset/current-opencode-dev.md index 976630f..b1b4054 100644 --- a/.changeset/current-opencode-dev.md +++ b/.changeset/current-opencode-dev.md @@ -2,4 +2,4 @@ "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. +Restore compatibility with current OpenCode V2 checkouts and packed Drive installations. Drive now uses V2's built-in simulation transport and provider shape, isolates scripted service ports and command forms, and compiles standalone scripts against the launching Drive toolchain without package installation or source-directory links. diff --git a/bun.lock b/bun.lock index 8e21658..beed928 100644 --- a/bun.lock +++ b/bun.lock @@ -39,8 +39,10 @@ "@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-16486", + "@opencode-ai/client": "0.0.0-next-16543", "@opentui/core": "0.4.5", + "@types/bun": "1.3.13", + "@typescript/native-preview": "7.0.0-dev.20251207.1", "@wterm/core": "0.3.0", "@wterm/ghostty": "0.3.0", "effect": "4.0.0-beta.101", @@ -48,8 +50,6 @@ "devDependencies": { "@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", "oxlint": "1.60.0", "oxlint-tsgolint": "0.21.0", "typescript": "5.8.2", @@ -168,11 +168,11 @@ "@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-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/client": ["@opencode-ai/client@0.0.0-next-16543", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-16543", "@opencode-ai/schema": "0.0.0-next-16543" }, "peerDependencies": { "effect": "4.0.0-beta.101" }, "optionalPeers": ["effect"] }, "sha512-TjVOLtl2P5/63mTxTRb0WF9IYrPEJwKvUKEMkeII/AHqkH/QmSMmIZjRJyHQMgc5QBC8qtw+hm8WRiCiudu6qw=="], - "@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/protocol": ["@opencode-ai/protocol@0.0.0-next-16543", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-16543", "effect": "4.0.0-beta.101" } }, "sha512-SIYM15csEIf2wtrVCM3Eg3+SCvG66gyjrckqEeTtrfntqAVH5+M9Yce5oefQyZr5vKL+akGzjX5ywsgMqb4DlA=="], - "@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-ai/schema": ["@opencode-ai/schema@0.0.0-next-16543", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-beta.101" } }, "sha512-X29NyL9urulLSRKjVFukqR6zFdW7uPcdnaup1L3JH46RL0NQIfnXPTpqC/tFBhbu6S4LlhP/SsfKj56ncvPVKQ=="], "@opencode-drive/catalog": ["@opencode-drive/catalog@workspace:apps/catalog"], diff --git a/packages/drive/README.md b/packages/drive/README.md index f438097..0210f01 100644 --- a/packages/drive/README.md +++ b/packages/drive/README.md @@ -24,8 +24,9 @@ npx skills add anomalyco/opencode-drive --agent opencode --skill opencode-drive ## Effect programs The primary way to automate OpenCode is a default-exported, fully provided -Effect. Drive type-checks the module contract before importing it, validates the -export at runtime, and runs it in the CLI's Effect runtime: +Effect. Drive type-checks the module contract, compiles the script and its local +imports against the launching Drive toolchain, then validates and runs the +export in an isolated Bun process: ```ts // drive.ts @@ -248,8 +249,7 @@ generated script is ready for `opencode-drive check ./drive.ts` and `start --script ./drive.ts`. ```ts -import { Effect } from "effect" -import { defineScript, Llm } from "opencode-drive" +import { defineScript, Effect, Llm } from "opencode-drive" export default defineScript({ config: { @@ -405,11 +405,11 @@ Type-check every new or edited script before running it: opencode-drive check ./drive.ts ``` -Drive temporarily exposes its script API and `tsgo` beside the script while -checking, then removes only the links it created. When it detects an old -Promise-style `setup`, `run`, or `ui.waitFor` callback, it prints the equivalent -Effect shape after the TypeScript diagnostics. Use `Effect.sleep(milliseconds)` -for unconditional delays. +Drive resolves its script API, Effect, Bun declarations, and `tsgo` from the +launching installation without installing packages or modifying the script's +directory. When it detects an old Promise-style `setup`, `run`, or `ui.waitFor` +callback, it prints the equivalent Effect shape after the TypeScript +diagnostics. Use `Effect.sleep(milliseconds)` for unconditional delays. The `fs`, `ui`, `llm`, `tools`, `server`, and `tuis` capabilities expose Effect-returning operations. Compose them with `yield*`, `Effect.flatMap`, or @@ -437,8 +437,8 @@ export default defineScript({ }) ``` -Only one server may be launched per script. All TUIs share its LLM backend. TUI processes and temporary -script links are cleaned up when the script ends. +Only one server may be launched per script. All TUIs share its LLM backend. TUI +processes and compiled script artifacts are cleaned up when the script ends. `yield* server.kill()` stops the server so it can be launched again later. `yield* tui.close()` closes a TUI, after which its name may be reused. diff --git a/packages/drive/package.json b/packages/drive/package.json index 250c32f..05cfeee 100644 --- a/packages/drive/package.json +++ b/packages/drive/package.json @@ -61,8 +61,10 @@ "@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-16486", + "@opencode-ai/client": "0.0.0-next-16543", "@opentui/core": "0.4.5", + "@types/bun": "1.3.13", + "@typescript/native-preview": "7.0.0-dev.20251207.1", "@wterm/core": "0.3.0", "@wterm/ghostty": "0.3.0", "effect": "4.0.0-beta.101" @@ -70,8 +72,6 @@ "devDependencies": { "@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", "oxlint": "1.60.0", "oxlint-tsgolint": "0.21.0", "typescript": "5.8.2", diff --git a/packages/drive/src/cli/index.ts b/packages/drive/src/cli/index.ts index 120975a..403e043 100644 --- a/packages/drive/src/cli/index.ts +++ b/packages/drive/src/cli/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun -import { NodeRuntime, NodeServices } from "@effect/platform-node" +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeServices from "@effect/platform-node/NodeServices" import { Effect, Option } from "effect" import { Argument, Command, Flag } from "effect/unstable/cli" import packageJson from "../../package.json" with { type: "json" } diff --git a/packages/drive/src/cli/run.ts b/packages/drive/src/cli/run.ts index cd215a2..8dc65d6 100644 --- a/packages/drive/src/cli/run.ts +++ b/packages/drive/src/cli/run.ts @@ -1,65 +1,32 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import { pathToFileURL } from "node:url" import * as Effect from "effect/Effect" -import { - prepareScriptTooling, - typecheckPreparedTooling, -} from "../script/tooling.js" +import * as Process from "../instance/process.js" +import { prepareProgram } from "../script/tooling.js" export const runProgram = Effect.fn("Cli.runProgram")((file: string) => Effect.acquireUseRelease( Effect.tryPromise({ - try: () => prepareProgram(resolve(file)), + try: () => mkdtemp(join(tmpdir(), "opencode-drive-run-")), catch: (cause) => cause, }), - ({ file }) => + (artifacts) => Effect.gen(function* () { - 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 - }, + const runner = yield* Effect.tryPromise({ + try: () => prepareProgram(artifacts, resolve(file)), catch: (cause) => cause, }) - return yield* program + const result = yield* Process.run([process.execPath, runner], { + extendEnv: true, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + if (result.status !== 0) + return yield* Effect.fail(new Error(`program exited with status ${result.status}`)) + return undefined }), - ({ remove }) => Effect.promise(remove), + (artifacts) => Effect.promise(() => rm(artifacts, { recursive: true, force: true })), ), ) - -async function prepareProgram(file: string) { - const artifacts = await mkdtemp(join(tmpdir(), "opencode-drive-run-")) - const contract = join(artifacts, "program-contract.ts") - let links: Awaited>["links"] | undefined - try { - await Bun.write( - contract, - [ - 'import type * as Effect from "effect/Effect"', - `import program from ${JSON.stringify(file)}`, - "const contract: Effect.Effect = program", - "void contract", - "", - ].join("\n"), - ) - const tooling = await prepareScriptTooling(artifacts, contract, file) - links = tooling.links - await typecheckPreparedTooling(tooling, artifacts, "program") - return { - file, - remove: async () => { - await links?.remove() - await rm(artifacts, { recursive: true, force: true }) - }, - } - } catch (error) { - await links?.remove() - await rm(artifacts, { recursive: true, force: true }) - throw error - } -} diff --git a/packages/drive/src/cli/script-init.ts b/packages/drive/src/cli/script-init.ts index a955527..ec86823 100644 --- a/packages/drive/src/cli/script-init.ts +++ b/packages/drive/src/cli/script-init.ts @@ -2,8 +2,7 @@ import { mkdir, open, rm } from "node:fs/promises" import { dirname, resolve } from "node:path" import { logSuccess } from "../log.js" -const template = `import { Effect } from "effect" -import { defineScript, Llm } from "opencode-drive" +const template = `import { defineScript, Effect, Llm } from "opencode-drive" export default defineScript({ project: { diff --git a/packages/drive/src/cli/start.ts b/packages/drive/src/cli/start.ts index 6b1cbf9..464e94a 100644 --- a/packages/drive/src/cli/start.ts +++ b/packages/drive/src/cli/start.ts @@ -12,7 +12,7 @@ import { connectMockBackend } from "./mock-backend.js" import { createResponseSettings } from "./response-generator.js" import { loadScript, runScript } from "./script.js" import type { ScriptDefinition } from "../script/types.js" -import { prepareScriptTooling } from "../script/tooling.js" +import { prepareScriptModule } from "../script/tooling.js" import { finalizeRecording } from "../recording/finalize.js" import { listenControl } from "../instance/control.js" import { configureLogFile, logError, logReadyPaths, logSuccess } from "../log.js" @@ -57,19 +57,15 @@ const startScoped = Effect.fn("DriveCli.startScoped")(function* (options: StartO if (!options.visible && !options.script && !options.daemon) return yield* startDetached(options, initialized.artifacts) const scriptPath = options.script - const scriptTooling = scriptPath - ? yield* Effect.acquireRelease( - fromPromise(async () => { - logSuccess(`preparing script ${scriptPath}`) - return prepareScriptTooling(initialized.artifacts, scriptPath) - }), - (tooling) => fromPromise(() => tooling.links.remove()).pipe(Effect.ignore), - ) + const scriptModule = scriptPath + ? yield* fromPromise(async () => { + logSuccess(`preparing script ${scriptPath}`) + return prepareScriptModule(initialized.artifacts, scriptPath) + }) : undefined - const script = scriptTooling - ? yield* loadScript(scriptTooling.file).pipe( - Effect.tap(() => Effect.sync(() => logSuccess(`loading script ${scriptTooling.file}`))), - Effect.onError(() => fromPromise(() => scriptTooling.links.remove()).pipe(Effect.ignore)), + const script = scriptModule + ? yield* loadScript(scriptModule).pipe( + Effect.tap(() => Effect.sync(() => logSuccess(`loading script ${scriptModule}`))), ) : undefined if (script && "launch" in script && options.record) { @@ -111,7 +107,7 @@ const startScoped = Effect.fn("DriveCli.startScoped")(function* (options: StartO ), () => fromPromise(() => unregister(options.name, process.pid)).pipe(Effect.ignore), ) - return yield* lifecycle(options, instance, responses, script, scriptTooling, log) + return yield* lifecycle(options, instance, responses, script, log) }) function lifecycle( @@ -119,12 +115,11 @@ function lifecycle( instance: OpenCodeInstance.Instance, responses: ReturnType, script: ScriptDefinition | undefined, - scriptTooling: Awaited> | undefined, log: (message: string) => void, ) { return Effect.callback((resume) => { const abort = new AbortController() - const promise = runLifecycle(options, instance, responses, script, scriptTooling, log, abort.signal) + const promise = runLifecycle(options, instance, responses, script, log, abort.signal) void promise.then( () => resume(Effect.void), (error) => resume(Effect.fail(error)), @@ -141,7 +136,6 @@ async function runLifecycle( instance: OpenCodeInstance.Instance, responses: ReturnType, script: ScriptDefinition | undefined, - scriptTooling: Awaited> | undefined, log: (message: string) => void, signal: AbortSignal, ) { @@ -331,10 +325,6 @@ async function runLifecycle( cleanupFailure ??= error logError(`failed to unregister ${options.name}: ${error}`) }) - await scriptTooling?.links.remove().catch((error) => { - cleanupFailure ??= error - logError(`failed to remove script tooling: ${error}`) - }) if (options.script && !options.visible) report(completed ? "completed" : undefined) if (options.script && recordingPath) logSuccess(`recording ${recordingPath}`) if (options.script) diff --git a/packages/drive/src/driver/index.ts b/packages/drive/src/driver/index.ts index d2d699c..cdadcef 100644 --- a/packages/drive/src/driver/index.ts +++ b/packages/drive/src/driver/index.ts @@ -3,7 +3,7 @@ import * as Cause from "effect/Cause" import * as Exit from "effect/Exit" import * as Layer from "effect/Layer" import type * as Scope from "effect/Scope" -import { NodeServices } from "@effect/platform-node" +import * as NodeServices from "@effect/platform-node/NodeServices" import * as OpenCodeInstance from "../instance/runtime.js" import * as SimulationConnector from "../simulation/connector.js" import type { diff --git a/packages/drive/src/driver/opencode.ts b/packages/drive/src/driver/opencode.ts index 433aa0e..cae9bec 100644 --- a/packages/drive/src/driver/opencode.ts +++ b/packages/drive/src/driver/opencode.ts @@ -1,5 +1,5 @@ import { join } from "node:path" -import { NodeFileSystem } from "@effect/platform-node" +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" import { OpenCode as OpenCodeService, type OpenCodeClient, diff --git a/packages/drive/src/index.ts b/packages/drive/src/index.ts index fbbb0c8..53ac01c 100644 --- a/packages/drive/src/index.ts +++ b/packages/drive/src/index.ts @@ -1,4 +1,5 @@ export * from "./script/index.js" +export * as Effect from "effect/Effect" export * as Llm from "./llm/index.js" export * as OpenCodeDriver from "./driver/index.js" export * as Errors from "./script/errors.js" diff --git a/packages/drive/src/instance/default-config.jsonc b/packages/drive/src/instance/default-config.jsonc index 717e3ce..f2d2d3c 100644 --- a/packages/drive/src/instance/default-config.jsonc +++ b/packages/drive/src/instance/default-config.jsonc @@ -11,16 +11,9 @@ "providers": { "simulation": { "name": "Simulation", - "api": { - "type": "aisdk", - "package": "@ai-sdk/openai-compatible", - "url": "https://api.openai.com/v1", - "settings": {} - }, - "request": { - "body": { - "apiKey": "sim-key" - } + "package": "@opencode-ai/ai/providers/openai/chat", + "settings": { + "apiKey": "sim-key" }, "models": { "gpt-sim-model": { diff --git a/packages/drive/src/instance/dev-integration.ts b/packages/drive/src/instance/dev-integration.ts deleted file mode 100644 index 86f8cf9..0000000 --- a/packages/drive/src/instance/dev-integration.ts +++ /dev/null @@ -1,514 +0,0 @@ -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 deleted file mode 100644 index 78a60a9..0000000 --- a/packages/drive/src/instance/dev-preload.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 5ca7618..9ffe966 100644 --- a/packages/drive/src/instance/dev.ts +++ b/packages/drive/src/instance/dev.ts @@ -33,10 +33,11 @@ export const prepareDev = Effect.fn("OpenCodeInstance.prepareDev")(function* ( const preloads = [ "--conditions=browser", `--preload=${join(solid, "scripts", "preload.js")}`, - `--preload=${new URL("./dev-preload.ts", import.meta.url).pathname}`, ] + const base = [process.execPath, ...preloads, entrypoint] return { - command: [process.execPath, ...preloads, entrypoint, ...(standalone ? ["--standalone"] : [])], + command: [...base, ...(standalone ? ["--standalone"] : [])], + scriptedCommand: base, preloads, } }) diff --git a/packages/drive/src/instance/instance.ts b/packages/drive/src/instance/instance.ts index 42f0dfa..11a8638 100644 --- a/packages/drive/src/instance/instance.ts +++ b/packages/drive/src/instance/instance.ts @@ -93,22 +93,6 @@ 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 2d21065..fe6d316 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 { configureDevSimulation, prepareInstanceProject } from "./instance.js" +import { prepareInstanceProject } from "./instance.js" import * as Process from "./process.js" import { freePort, waitForWebSocket } from "./readiness.js" import { isValidName } from "./registry.js" @@ -140,11 +140,6 @@ 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 @@ -173,6 +168,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( : options.command?.length ? [...options.command] : ["opencode2"] + const scriptedCommand = dev?.scriptedCommand ?? command const initialRecording = options.record ? recordingPaths(media) : undefined const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner const fileSystem = yield* FileSystem.FileSystem @@ -262,12 +258,19 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( if (current.server !== undefined || current.pendingServer !== undefined) return yield* Effect.fail(instanceError("launch server", "the script server has already been launched")) const name = processDriveName(options.name, "service") + const serverCommand = [ + ...scriptedCommand, + "serve", + "--service", + "--port", + String(yield* freePort), + ] yield* writeManifest(name, { ui: `ws://127.0.0.1:${yield* freePort}`, backend: endpoints.backend, }) options.log?.("launching script server") - const server = yield* spawn(name, [...command, "serve", "--service"], "service", false) + const server = yield* spawn(name, serverCommand, "service", false) yield* Ref.update(state, (value) => ({ ...value, pendingServer: server })) return server }), @@ -376,7 +379,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( tuiOptions.viewport ?? options.viewport, ) options.log?.(`launching TUI ${name}`) - const tui = yield* spawn(driveName, command, `tui-${name}`, options.visible ?? false) + const tui = yield* spawn(driveName, scriptedCommand, `tui-${name}`, options.visible ?? false) yield* Ref.update(state, (value) => ({ ...value, pendingTuis: new Map(value.pendingTuis).set(name, tui), diff --git a/packages/drive/src/recording/ffmpeg.ts b/packages/drive/src/recording/ffmpeg.ts index 5921f86..ba3bc22 100644 --- a/packages/drive/src/recording/ffmpeg.ts +++ b/packages/drive/src/recording/ffmpeg.ts @@ -1,4 +1,4 @@ -import { NodeServices } from "@effect/platform-node" +import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" import * as Process from "../instance/process.js" diff --git a/packages/drive/src/script/project.ts b/packages/drive/src/script/project.ts index 29b428e..2639ffa 100644 --- a/packages/drive/src/script/project.ts +++ b/packages/drive/src/script/project.ts @@ -1,7 +1,7 @@ import { lstat, readdir, rm } from "node:fs/promises" import { devNull } from "node:os" import { join } from "node:path" -import { NodeServices } from "@effect/platform-node" +import * as NodeServices from "@effect/platform-node/NodeServices" import * as Effect from "effect/Effect" import * as Process from "../instance/process.js" import { writeScriptFiles } from "./filesystem.js" diff --git a/packages/drive/src/script/tooling.ts b/packages/drive/src/script/tooling.ts index 8641fa9..6fa1d23 100644 --- a/packages/drive/src/script/tooling.ts +++ b/packages/drive/src/script/tooling.ts @@ -1,48 +1,99 @@ -import { lstat, mkdir, readlink, rm, rmdir, stat, symlink } from "node:fs/promises" +import { mkdir, rm, symlink } from "node:fs/promises" +import { createRequire } from "node:module" import { dirname, join, resolve } from "node:path" import { fileURLToPath } from "node:url" -import { NodeServices } from "@effect/platform-node" -import * as Effect from "effect/Effect" -import * as Process from "../instance/process.js" const packageRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))) +const requireFromDrive = createRequire(join(packageRoot, "package.json")) +const effectRoot = dirname(requireFromDrive.resolve("effect/package.json")) +const compilerRoot = dirname(requireFromDrive.resolve("@typescript/native-preview/package.json")) +const bunTypesRoot = dirname(requireFromDrive.resolve("@types/bun/package.json")) +const requireFromBunTypes = createRequire(join(bunTypesRoot, "package.json")) +const bunRuntimeTypesRoot = dirname(requireFromBunTypes.resolve("bun-types/package.json")) -export async function prepareScriptTooling( - artifacts: string, - script: string, - dependencyEntry: string = script, -) { - const file = resolve(script) - if (!(await Bun.file(file).exists())) throw new Error(`script not found: ${file}`) - const packageJson: unknown = await Bun.file(join(packageRoot, "package.json")).json() - if (!isPackageMetadata(packageJson)) - throw new Error("opencode-drive package metadata is missing script dependencies") - const dependencies = { - "opencode-drive": `file:${packageRoot}`, - effect: packageJson.dependencies.effect, - "@typescript/native-preview": - packageJson.devDependencies["@typescript/native-preview"], - "@types/bun": packageJson.devDependencies["@types/bun"], - } - const manifest = join(artifacts, "package.json") - const contents = `${JSON.stringify({ private: true, dependencies }, undefined, 2)}\n` - if (!(await Bun.file(manifest).exists()) || (await Bun.file(manifest).text()) !== contents) { - await Bun.write(manifest, contents) - } - if (!(await linkLocalTooling(artifacts))) { - const install = await runProcess( - [process.execPath, "install"], - artifacts, - { stdout: "ignore" }, - ) - if (install.status !== 0) - throw new Error( - `bun install failed in ${artifacts}: ${install.stderr.trim()}`, - ) +type Contract = "program" | "script" + +export async function prepareScriptModule(artifacts: string, script: string) { + const source = await resolveSource(script, "script") + const root = await prepareRuntimeRoot(artifacts) + return compileScript(root, source) +} + +export async function prepareProgram(artifacts: string, script: string) { + const source = await resolveSource(script, "program") + const root = await prepareRuntimeRoot(artifacts) + await typecheck(root, source, "program") + const program = await compileScript(root, source) + const runner = join(root, "program-runner.ts") + await Bun.write( + runner, + [ + 'import * as Effect from "effect/Effect"', + `import program from ${JSON.stringify(program)}`, + 'if (!Effect.isEffect(program)) throw new Error("program must default-export a fully provided Effect")', + "await Effect.runPromise(program)", + "", + ].join("\n"), + ) + return runner +} + +export async function checkScript(artifacts: string, script: string) { + const source = await resolveSource(script, "script") + const root = await prepareRuntimeRoot(artifacts) + await typecheck(root, source, "script") +} + +async function resolveSource(script: string, contract: Contract) { + const source = resolve(script) + if (!(await Bun.file(source).exists())) throw new Error(`${contract} not found: ${source}`) + return source +} + +async function compileScript(root: string, source: string) { + const entry = join(root, "script-entry.ts") + const output = join(root, "compiled") + await Bun.write(entry, `export { default } from ${JSON.stringify(source)}\n`) + const result = await Bun.build({ + entrypoints: [entry], + outdir: output, + target: "bun", + packages: "bundle", + external: ["opencode-drive", "opencode-drive/*", "effect", "effect/*"], + sourcemap: "inline", + }) + if (!result.success) { + const diagnostics = result.logs.map((log) => log.message).join("\n") + throw new Error(diagnostics || `failed to compile script: ${source}`) } + const file = result.outputs.find((item) => item.kind === "entry-point")?.path + if (!file) throw new Error(`script compilation produced no entry point: ${source}`) + return file +} +async function typecheck(root: string, source: string, contract: Contract) { + const contractFile = join(root, `${contract}-contract.ts`) + const tsconfig = join(root, `${contract}-tsconfig.json`) + await Bun.write( + contractFile, + contract === "program" + ? [ + 'import type * as Effect from "effect/Effect"', + `import program from ${JSON.stringify(source)}`, + "const checked: Effect.Effect = program", + "void checked", + "", + ].join("\n") + : [ + 'import type { ScriptDefinition } from "opencode-drive/script"', + `import script from ${JSON.stringify(source)}`, + "const checked: ScriptDefinition = script", + "void checked", + "", + ].join("\n"), + ) await Bun.write( - join(artifacts, "tsconfig.json"), + tsconfig, `${JSON.stringify( { compilerOptions: { @@ -57,192 +108,54 @@ export async function prepareScriptTooling( allowImportingTsExtensions: true, skipLibCheck: true, types: ["bun"], + paths: { + "opencode-drive": [join(packageRoot, "src", "index.ts")], + "opencode-drive/*": [join(packageRoot, "src", "*")], + effect: [join(effectRoot, "dist", "index.d.ts")], + "effect/*": [join(effectRoot, "dist", "*.d.ts")], + }, }, - files: [file], + files: [contractFile], }, undefined, 2, )}\n`, ) - return { - file, - tsgo: join(artifacts, "node_modules", ".bin", "tsgo"), - tsconfig: join(artifacts, "tsconfig.json"), - links: await linkScriptDependencies(resolve(dependencyEntry), artifacts), - } -} - -async function linkLocalTooling(artifacts: string) { - const local = { - drive: packageRoot, - effect: join(packageRoot, "node_modules", "effect"), - tsgo: join(packageRoot, "node_modules", ".bin", "tsgo"), - bunTypes: join(packageRoot, "node_modules", "@types", "bun"), - } - if ( - !(await stat(local.tsgo).catch(() => undefined)) || - !(await stat(local.effect).catch(() => undefined)) || - !(await stat(local.bunTypes).catch(() => undefined)) + const capture = contract === "script" + const child = Bun.spawn( + [process.execPath, join(compilerRoot, "bin", "tsgo.js"), "-p", tsconfig], + { + cwd: root, + stdin: "ignore", + stdout: capture ? "pipe" : "inherit", + stderr: capture ? "pipe" : "inherit", + }, ) - return false - const modules = join(artifacts, "node_modules") - await Promise.all([ - mkdir(join(modules, ".bin"), { recursive: true }), - mkdir(join(modules, "@types"), { recursive: true }), + const [status, stdout, stderr] = await Promise.all([ + child.exited, + capture ? new Response(child.stdout).text() : "", + capture ? new Response(child.stderr).text() : "", ]) - const created: string[] = [] - try { - for (const [target, path] of [ - [local.drive, join(modules, "opencode-drive")], - [local.effect, join(modules, "effect")], - [local.tsgo, join(modules, ".bin", "tsgo")], - [local.bunTypes, join(modules, "@types", "bun")], - ] as const) { - await symlink(target, path) - created.push(path) - } - return true - } catch { - await Promise.all(created.map((path) => rm(path, { force: true }))) - return false - } -} - -function isPackageMetadata( - value: unknown, -): value is { - readonly dependencies: { readonly effect: string } - readonly devDependencies: Record -} { - if ( - typeof value !== "object" || - value === null || - !("dependencies" in value) || - !("devDependencies" in value) - ) - return false - const runtime = value.dependencies - const dependencies = value.devDependencies - return ( - typeof runtime === "object" && - runtime !== null && - "effect" in runtime && - typeof runtime.effect === "string" && - typeof dependencies === "object" && - dependencies !== null && - "@typescript/native-preview" in dependencies && - typeof dependencies["@typescript/native-preview"] === "string" && - "@types/bun" in dependencies && - typeof dependencies["@types/bun"] === "string" - ) -} - -export async function checkScript(artifacts: string, script: string) { - const file = resolve(script) - const contract = join(artifacts, "script-contract.ts") - await Bun.write( - contract, - [ - 'import type { ScriptDefinition } from "opencode-drive/script"', - `import script from ${JSON.stringify(file)}`, - "const contract: ScriptDefinition = script", - "void contract", - "", - ].join("\n"), - ) - const tooling = await prepareScriptTooling(artifacts, contract, file) - try { - await typecheckPreparedTooling(tooling, artifacts, "script", true) - } finally { - await tooling.links.remove() - } + if (status === 0) return + const diagnostics = [stdout, stderr] + .map((value) => value.trim()) + .filter(Boolean) + .join("\n") + throw new Error(diagnostics || `${contract} type check failed with status ${status}`) } -export async function typecheckPreparedTooling( - tooling: Awaited>, - cwd: string, - label: string, - capture: boolean = false, -) { - const output = await runProcess( - [tooling.tsgo, "-p", tooling.tsconfig], - cwd, - capture ? {} : { stdout: "inherit", stderr: "inherit" }, - ) - if (output.status !== 0) { - const diagnostics = [output.stdout, output.stderr] - .map((value) => value.trim()) - .filter(Boolean) - .join("\n") - throw new Error( - diagnostics || `${label} type check failed with status ${output.status}`, - ) - } +async function prepareRuntimeRoot(artifacts: string) { + const root = join(resolve(artifacts), "script-runtime") + const modules = join(root, "node_modules") + await rm(root, { recursive: true, force: true }) + await mkdir(join(modules, "@types"), { recursive: true }) + await Promise.all([ + symlink(packageRoot, join(modules, "opencode-drive"), linkType), + symlink(effectRoot, join(modules, "effect"), linkType), + symlink(bunTypesRoot, join(modules, "@types", "bun"), linkType), + symlink(bunRuntimeTypesRoot, join(modules, "bun-types"), linkType), + ]) + return root } -const runProcess = ( - command: ReadonlyArray, - cwd: string, - options: Omit = {}, -) => - Effect.runPromise( - Process.run(command, { ...options, cwd }).pipe( - Effect.provide(NodeServices.layer), - ), - ) - -async function linkScriptDependencies(script: string, artifacts: string) { - const modules = join(dirname(script), "node_modules") - const created: Array<{ readonly path: string; readonly target: string }> = [] - const directories: string[] = [] - const add = async (path: string, target: string) => { - const existing = await lstat(path).catch(() => undefined) - if (existing) { - const previous = existing.isSymbolicLink() - ? await readlink(path).catch(() => undefined) - : undefined - if ( - previous !== undefined && - /(?:^|[\\/])opencode-drive[\\/]run-/.test(previous) && - !(await Bun.file(previous).exists()) - ) { - await rm(path, { force: true }) - } else { - return - } - } - const parent = dirname(path) - if (!(await lstat(parent).catch(() => undefined))) { - await mkdir(parent, { recursive: true }) - directories.push(parent) - } - await symlink(target, path) - created.push({ path, target }) - } - const remove = async () => { - for (const link of created.reverse()) { - const target = await readlink(link.path).catch(() => undefined) - if (target === link.target) await rm(link.path, { force: true }) - } - for (const path of directories.reverse()) - await rmdir(path).catch(() => undefined) - } - try { - await add( - join(modules, "opencode-drive"), - join(artifacts, "node_modules", "opencode-drive"), - ) - await add( - join(modules, "effect"), - join(artifacts, "node_modules", "effect"), - ) - await add( - join(modules, ".bin", "tsgo"), - join(artifacts, "node_modules", ".bin", "tsgo"), - ) - return { remove } - } catch (error) { - await remove() - throw error - } -} +const linkType = process.platform === "win32" ? "junction" : "dir" diff --git a/packages/drive/test/cli/integration.test.ts b/packages/drive/test/cli/integration.test.ts index 2f5cd1f..aa16f0c 100644 --- a/packages/drive/test/cli/integration.test.ts +++ b/packages/drive/test/cli/integration.test.ts @@ -121,11 +121,8 @@ describe("opencode-drive", () => { permissions: [{ action: "*", resource: "*", effect: "allow" }], providers: { simulation: { - api: { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.openai.com/v1", - }, + package: "@opencode-ai/ai/providers/openai/chat", + settings: { apiKey: "sim-key" }, models: { "gpt-sim-model": { capabilities: { tools: true } } }, }, }, @@ -823,7 +820,12 @@ describe("opencode-drive", () => { expect(await realpath(await Bun.file(join(artifacts, "child-cwd.txt")).text())).toBe( await realpath(join(artifacts, "files")), ) - expect(await Bun.file(join(artifacts, "service-argv.json")).json()).toEqual(["serve", "--service"]) + expect(await Bun.file(join(artifacts, "service-argv.json")).json()).toEqual([ + "serve", + "--service", + "--port", + expect.stringMatching(/^\d+$/), + ]) const pid = Number(await Bun.file(join(artifacts, "child.pid")).text()) expect(running(pid)).toBe(false) expect(await Bun.file(join(root, "registry", `${name}.json`)).exists()).toBe(false) @@ -919,7 +921,7 @@ describe("opencode-drive", () => { expect(await Bun.file(artifacts).exists()).toBe(false) }) - test("checks a typed script and removes temporary dependency links", async () => { + test("checks a typed script without modifying its source directory", async () => { const root = await temporary() const directory = join(root, "scripts") await mkdir(directory, { recursive: true }) @@ -971,7 +973,7 @@ describe("opencode-drive", () => { expect(status).toBe(0) expect(stdout.trim()).toBe(file) const source = await Bun.file(file).text() - expect(source).toContain('import { defineScript, Llm } from "opencode-drive"') + expect(source).toContain('import { defineScript, Effect, Llm } from "opencode-drive"') expect(source).toContain('llm.queue(Llm.text("The value is 1."))') const checked = spawn(["check", file], root) @@ -1156,12 +1158,11 @@ describe("opencode-drive", () => { test.each(["setup", "run"] as const)("rejects a Promise-returning script %s callback", async (callback) => { const root = await temporary() const script = join(root, `promise-${callback}-script.js`) - const drive = JSON.stringify(resolve("src/index.ts")) await Bun.write( script, callback === "setup" - ? `import { Effect } from "effect"\nimport { defineScript } from ${drive}\nexport default defineScript({ setup: async () => {}, run: () => Effect.void })\n` - : `import { defineScript } from ${drive}\nexport default defineScript({ run: async () => {} })\n`, + ? `import { Effect } from "effect"\nimport { defineScript } from "opencode-drive"\nexport default defineScript({ setup: async () => {}, run: () => Effect.void })\n` + : `import { defineScript } from "opencode-drive"\nexport default defineScript({ run: async () => {} })\n`, ) const child = spawn( [ @@ -1189,7 +1190,7 @@ describe("opencode-drive", () => { const script = join(root, "promise-predicate-script.js") await Bun.write( script, - `import { defineScript } from ${JSON.stringify(resolve("src/index.ts"))}\nexport default defineScript({ run: ({ ui }) => ui.waitFor(() => Promise.resolve(false)) })\n`, + `import { defineScript } from "opencode-drive"\nexport default defineScript({ run: ({ ui }) => ui.waitFor(() => Promise.resolve(false)) })\n`, ) const child = spawn( [ @@ -1424,7 +1425,7 @@ describe("opencode-drive", () => { const marker = join(root, "script-interrupted") await Bun.write( script, - `import { Effect } from "effect"\nimport { defineScript } from ${JSON.stringify(resolve("src/index.ts"))}\nexport default defineScript({ run: ({ artifacts }) => Effect.never.pipe(Effect.ensuring(Effect.promise(async () => { const pid = Number(await Bun.file(artifacts + "/child.pid").text()); let running = true; try { process.kill(pid, 0) } catch { running = false }; await Bun.write(${JSON.stringify(marker)}, running ? "child-running\\n" : "child-stopped\\n") }))) })\n`, + `import { Effect } from "effect"\nimport { defineScript } from "opencode-drive"\nexport default defineScript({ run: ({ artifacts }) => Effect.never.pipe(Effect.ensuring(Effect.promise(async () => { const pid = Number(await Bun.file(artifacts + "/child.pid").text()); let running = true; try { process.kill(pid, 0) } catch { running = false }; await Bun.write(${JSON.stringify(marker)}, running ? "child-running\\n" : "child-stopped\\n") }))) })\n`, ) const child = spawn( [ diff --git a/packages/drive/test/cli/run.test.ts b/packages/drive/test/cli/run.test.ts index 52402d0..a70decd 100644 --- a/packages/drive/test/cli/run.test.ts +++ b/packages/drive/test/cli/run.test.ts @@ -15,10 +15,11 @@ describe("opencode-drive run", () => { test("type-checks and executes a default-exported Effect", async () => { const root = await temporary() const marker = join(root, "executed.txt") + await Bun.write(join(root, "helper.ts"), 'export const value = "yes\\n"\n') const program = await writeProgram( root, "valid.ts", - `import { Effect } from "effect"\nexport default Effect.promise(() => Bun.write(${JSON.stringify(marker)}, "yes\\n"))\n`, + `import { Effect } from "effect"\nimport { OpenCodeDriver } from "opencode-drive"\nimport { value } from "./helper.ts"\nvoid OpenCodeDriver\nexport default Effect.promise(() => Bun.write(${JSON.stringify(marker)}, value))\n`, ) const child = spawn(["run", program]) diff --git a/packages/drive/test/fixtures/caught-timeout-script.ts b/packages/drive/test/fixtures/caught-timeout-script.ts index 7e9389e..2d50244 100644 --- a/packages/drive/test/fixtures/caught-timeout-script.ts +++ b/packages/drive/test/fixtures/caught-timeout-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/crashing-script.ts b/packages/drive/test/fixtures/crashing-script.ts index 7392871..4eb2a46 100644 --- a/packages/drive/test/fixtures/crashing-script.ts +++ b/packages/drive/test/fixtures/crashing-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/failed-launch-retry-script.ts b/packages/drive/test/fixtures/failed-launch-retry-script.ts index 50726c7..b5bcbae 100644 --- a/packages/drive/test/fixtures/failed-launch-retry-script.ts +++ b/packages/drive/test/fixtures/failed-launch-retry-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/fake-opencode.ts b/packages/drive/test/fixtures/fake-opencode.ts index 3aa1cff..741554c 100644 --- a/packages/drive/test/fixtures/fake-opencode.ts +++ b/packages/drive/test/fixtures/fake-opencode.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path" const serviceMode = process.env.OPENCODE_DRIVE_SCRIPTED === "1" const role = serviceMode - ? process.argv.at(-2) === "serve" && process.argv.at(-1) === "--service" + ? process.argv.includes("serve") && process.argv.includes("--service") ? "service" : "client" : "legacy" diff --git a/packages/drive/test/fixtures/hanging-script.ts b/packages/drive/test/fixtures/hanging-script.ts index 8cded87..c283a9f 100644 --- a/packages/drive/test/fixtures/hanging-script.ts +++ b/packages/drive/test/fixtures/hanging-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/kill-server-script.ts b/packages/drive/test/fixtures/kill-server-script.ts index 1b30661..62077fc 100644 --- a/packages/drive/test/fixtures/kill-server-script.ts +++ b/packages/drive/test/fixtures/kill-server-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/manual-clients-script.ts b/packages/drive/test/fixtures/manual-clients-script.ts index 55ba3fd..e18c882 100644 --- a/packages/drive/test/fixtures/manual-clients-script.ts +++ b/packages/drive/test/fixtures/manual-clients-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/prepared-git-script.ts b/packages/drive/test/fixtures/prepared-git-script.ts index bf6c4a7..a7a2bcd 100644 --- a/packages/drive/test/fixtures/prepared-git-script.ts +++ b/packages/drive/test/fixtures/prepared-git-script.ts @@ -1,5 +1,5 @@ import { join } from "node:path" -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" let setupGitError: string | undefined diff --git a/packages/drive/test/fixtures/restart-script.ts b/packages/drive/test/fixtures/restart-script.ts index dd2e86b..eeb66cb 100644 --- a/packages/drive/test/fixtures/restart-script.ts +++ b/packages/drive/test/fixtures/restart-script.ts @@ -1,4 +1,4 @@ -import { defineScript, Llm } from "../../src/index.js" +import { defineScript, Llm } from "opencode-drive" import * as Effect from "effect/Effect" import * as Stream from "effect/Stream" diff --git a/packages/drive/test/fixtures/script.ts b/packages/drive/test/fixtures/script.ts index 8133641..dab2989 100644 --- a/packages/drive/test/fixtures/script.ts +++ b/packages/drive/test/fixtures/script.ts @@ -1,5 +1,5 @@ import { join } from "node:path" -import { defineScript, Llm } from "../../src/index.js" +import { defineScript, Llm } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/serve-script.ts b/packages/drive/test/fixtures/serve-script.ts index f2c7e68..bba8e6e 100644 --- a/packages/drive/test/fixtures/serve-script.ts +++ b/packages/drive/test/fixtures/serve-script.ts @@ -1,4 +1,4 @@ -import { defineScript, Llm } from "../../src/index.js" +import { defineScript, Llm } from "opencode-drive" import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Stream from "effect/Stream" diff --git a/packages/drive/test/fixtures/title-script.ts b/packages/drive/test/fixtures/title-script.ts index b081f10..17d3374 100644 --- a/packages/drive/test/fixtures/title-script.ts +++ b/packages/drive/test/fixtures/title-script.ts @@ -1,4 +1,4 @@ -import { defineScript, Llm } from "../../src/index.js" +import { defineScript, Llm } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/fixtures/tui-options-script.ts b/packages/drive/test/fixtures/tui-options-script.ts index 0580c2c..a2f44e0 100644 --- a/packages/drive/test/fixtures/tui-options-script.ts +++ b/packages/drive/test/fixtures/tui-options-script.ts @@ -1,4 +1,4 @@ -import { defineScript } from "../../src/index.js" +import { defineScript } from "opencode-drive" import * as Effect from "effect/Effect" export default defineScript({ diff --git a/packages/drive/test/instance/config.test.ts b/packages/drive/test/instance/config.test.ts index 7b51c79..ca10ac4 100644 --- a/packages/drive/test/instance/config.test.ts +++ b/packages/drive/test/instance/config.test.ts @@ -3,7 +3,6 @@ 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" @@ -27,28 +26,8 @@ describe("instance configuration", () => { 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" }, + package: "@opencode-ai/ai/providers/openai/chat", + settings: { apiKey: "sim-key" }, }, }, }) diff --git a/packages/drive/test/instance/dev-integration.test.ts b/packages/drive/test/instance/dev-integration.test.ts deleted file mode 100644 index 4a173d8..0000000 --- a/packages/drive/test/instance/dev-integration.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 index 733e50a..31672c0 100644 --- a/packages/drive/test/instance/dev.test.ts +++ b/packages/drive/test/instance/dev.test.ts @@ -26,6 +26,7 @@ test("uses standalone mode and inheritable preloads for V2 development checkouts const result = await Effect.runPromise(prepareDev(artifacts, root)) expect(result.command.at(-1)).toBe("--standalone") + expect(result.scriptedCommand.at(-1)).toBe(join(root, "packages", "cli", "src", "index.ts")) expect(result.preloads).toContain("--conditions=browser") expect(result.preloads).toContain( `--preload=${join(root, "packages", "tui", "node_modules", "@opentui", "solid", "scripts", "preload.js")}`,