name: opencode-drive
Use opencode-drive to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits, stops all processes, and cleans up all artifacts.
Use init when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later start with the same name reuses it.
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
The simulated project is under $artifacts/files. Running start without a prior init initializes the artifacts automatically.
You can write scripts that walk through entire flows, and gives you full access to controlling the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
opencode-drive check ./reproduce-stale-exploring-empty.ts
Run it by passing --script to start:
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
It will output information about the run, including paths to log files which you can read to inspect what happened. If you need to dig into failures that aren't clear, read those log files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. setup runs before OpenCode starts,
and fs.writeFile always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
setup receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this config parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the patch tool for working with files Do not use a edit or write tool to edit files.
Use launch: "manual" when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode ui is typed as null; call server.launch() exactly
once before launching clients. Each clients.launch(name) result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported wait(milliseconds) utility for an unconditional delay.
await llm.send(...) waits for the next request and resolves after OpenCode
acknowledges its complete response. llm.queue(...) declares responses in
advance. Chunks may be built with text, reasoning, toolCall, raw,
finish, and disconnect. A normal response receives finish("stop")
automatically unless it yields or queues an explicit terminal event.
llm.text(text, { delay, chunkSize }) defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
llm.reasoning accepts the same options, and llm.pause(milliseconds) adds a
delay between any two outputs.
Use llm.serve for an ongoing typed response generator:
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
The backend connection, response cleanup, cancellation, and recording completion are automatic.
You can see some example scripts here:
prune removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
--name. Visible instances may omit it.start detaches automatically and returns after the instance is ready.&; the long-running owner already runs in the background.send.Always stop the instance when finished.
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
send opens a connection to the named instance, runs its commands in order, and exits.Commands:
--command.ui.type <json> types into the focused editor. Arguments: text string.--command.ui.press <json> presses a key. Arguments: key string; optional modifiers object with boolean ctrl, shift, meta, super, or hyper.--command.ui.enter presses Enter. Arguments: none.--command.ui.arrow <json> presses an arrow key. Arguments: direction is up, down, left, or right.--command.ui.focus <json> focuses an element. Arguments: target is the numeric element num returned by ui.state.--command.ui.click <json> clicks an element. Arguments: numeric target, x, and y; use the element num returned by ui.state as target.--command.ui.state prints focus and interactive element metadata as JSON. Arguments: none.--command.ui.matches <json> prints whether literal, case-sensitive text appears on screen. Arguments: text string.
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
To read the UI state and see information about interactable elements, use the ui.state command:
opencode-drive send --name demo --command.ui.state
responses controls what the LLM responds withtext,reasoning,diff,tool with write,apply_patch.text, reasoning, diff, and tool.--tools limits generated tool calls to names offered by OpenCode.
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
ui.state prints focus and interactive element metadata as JSON.ui.matches checks for literal, case-sensitive screen text.screenshot prints the generated image path.
opencode-drive screenshot --name demo
stop waits for recording export and owner cleanup before returning.
opencode-drive stop --name demo
--record to capture a headless instance from its first rendered frame.stop finishes the recording, exports an MP4, and prints its path.
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
dir prints the artifact directory for the instance.
opencode-drive dir --name demo