Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/current-opencode-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-drive": patch
---

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.
2 changes: 1 addition & 1 deletion apps/catalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 57 additions & 18 deletions bun.lock

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions packages/drive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 8 additions & 7 deletions packages/drive/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,20 @@
"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-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.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",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"typescript": "5.8.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/drive/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
63 changes: 16 additions & 47 deletions packages/drive/src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -1,63 +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 module = yield* Effect.tryPromise({
try: () => import(pathToFileURL(file).href),
const runner = yield* Effect.tryPromise({
try: () => prepareProgram(artifacts, resolve(file)),
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
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<ReturnType<typeof prepareScriptTooling>>["links"] | undefined
try {
await Bun.write(
contract,
[
'import type * as Effect from "effect/Effect"',
`import program from ${JSON.stringify(file)}`,
"const contract: Effect.Effect<unknown, unknown, never> = 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
}
}
3 changes: 1 addition & 2 deletions packages/drive/src/cli/script-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
32 changes: 11 additions & 21 deletions packages/drive/src/cli/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -111,20 +107,19 @@ 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(
options: StartOptions,
instance: OpenCodeInstance.Instance,
responses: ReturnType<typeof createResponseSettings>,
script: ScriptDefinition | undefined,
scriptTooling: Awaited<ReturnType<typeof prepareScriptTooling>> | undefined,
log: (message: string) => void,
) {
return Effect.callback<void, unknown>((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)),
Expand All @@ -141,7 +136,6 @@ async function runLifecycle(
instance: OpenCodeInstance.Instance,
responses: ReturnType<typeof createResponseSettings>,
script: ScriptDefinition | undefined,
scriptTooling: Awaited<ReturnType<typeof prepareScriptTooling>> | undefined,
log: (message: string) => void,
signal: AbortSignal,
) {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/driver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/drive/src/driver/opencode.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/drive/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
9 changes: 2 additions & 7 deletions packages/drive/src/instance/default-config.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,9 @@
"providers": {
"simulation": {
"name": "Simulation",
"package": "aisdk:@ai-sdk/openai-compatible",
"package": "@opencode-ai/ai/providers/openai/chat",
"settings": {
"baseURL": "https://api.openai.com/v1"
},
"request": {
"body": {
"apiKey": "sim-key"
}
"apiKey": "sim-key"
},
"models": {
"gpt-sim-model": {
Expand Down
15 changes: 10 additions & 5 deletions packages/drive/src/instance/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -26,13 +26,18 @@ export const prepareDev = Effect.fn("OpenCodeInstance.prepareDev")(function* (
})
await rm(preload, { recursive: true, force: true })
await symlink(solid, preload, "dir")
return Bun.file(join(root, "packages", "cli", "src", "services", "standalone.ts")).exists()
},
catch: (cause) => instanceError("prepare development checkout", cause),
})
return [
process.execPath,
const preloads = [
"--conditions=browser",
"--preload=@opentui/solid/preload",
entrypoint,
`--preload=${join(solid, "scripts", "preload.js")}`,
]
const base = [process.execPath, ...preloads, entrypoint]
return {
command: [...base, ...(standalone ? ["--standalone"] : [])],
scriptedCommand: base,
preloads,
}
})
2 changes: 1 addition & 1 deletion packages/drive/src/instance/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, unknown> = options.setup({
fs: createScriptFileSystem(files, { git: protectGit }),
config,
tuiConfig: tui,
Expand Down
Loading