Skip to main content

opencode-drive

Use when an agent needs to drive OpenCode with an Effect program or interact with an isolated instance

Source facts

Repository
anomalyco/opencode-drive
Last source activity
October 14, 2026 at 09:27
Detected SKILL.md language
English
Stars
34
Forks
6

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
opencode-drive
description
Use when an agent needs to drive OpenCode with an Effect program or interact with an isolated instance
# OpenCode Drive Use `opencode-drive` to launch an isolated OpenCode instance and control its TUI and simulated LLM. Default to a one-shot Effect program. Use `defineScript` for named, visible, restartable, or manual-launch workflows; it is also Effect-only. Use live commands only for interactive development against a persistent or visible instance. ## Catalog State IDs Browse and copy OpenCode terminal state IDs from: ```text https://dev.opencode.ai/lab/catalog ``` Replayable flow states expose canonical `<flow-id>/<state-id>` addresses, for example: ```text patch-success-lifecycle/permission-prompt ``` Reproduce one from an `opencode-drive` source checkout: ```bash bun run catalog:reproduce -- patch-success-lifecycle/permission-prompt \ --opencode /path/to/opencode \ --output /tmp/permission-prompt.frame.json ``` The command executes the registered recipe only through that checkpoint and writes an `opencode-terminal-frame-v1` artifact. Only flows in `apps/catalog/scenarios/index.ts` are replayable. Browse-only flows and screen cards copy standalone capture IDs instead; do not invent a flow prefix. Use a protocol-compatible OpenCode checkout, ideally the source revision shown by the selected capture set. To compare a committed local OpenCode branch against current v2 across every catalog state: ```bash bun run catalog:capture -- \ --opencode /path/to/opencode \ --revision origin/v2 \ --revision HEAD ``` Add repeated `--theme` flags to capture the same commit pair under multiple themes. Capture resolves detached immutable worktrees, retains earlier sets, and sorts sets by commit time. Uncommitted changes are excluded by design. ## Effect Programs Write `drive.ts` as a default-exported, fully provided Effect, then run it directly: ```ts import { Effect } from "effect" import { Llm, OpenCodeDriver } from "opencode-drive" export default OpenCodeDriver.use( { project: { git: true, files: { "src/value.ts": "export const value = 1\n", }, }, }, ({ ui, llm }) => Effect.gen(function* () { yield* llm.queue(Llm.text("The value is 1.")) yield* ui.submit("Read src/value.ts") yield* ui.waitFor("The value is 1.") yield* ui.screenshot("result") }), ) ``` ```bash opencode-drive run ./drive.ts ``` `run` type-checks the module before importing it and requires its default export to be an `Effect<unknown, unknown, never>`. It accepts exactly one module path; it does not accept `--command.*` flags or application arguments after `--`. `OpenCodeDriver.use` is the normal lifecycle boundary. It creates an isolated project, starts the server and primary TUI, races the program against backend failure, settles queued LLM work, closes all TUIs, exports recordings, and removes the artifact directory unless `keepArtifacts: true` is set. Settlement failures fail the program. Use `OpenCodeDriver.make` only when explicit settlement is necessary. It requires a scope, and the program must call `driver.settle()` before leaving that scope. ### Deterministic Project DSL Declare the project, semantic OpenCode configuration, and TUI configuration in `OpenCodeDriver.use` options: ```ts export default OpenCodeDriver.use( { project: { git: true, files: { "README.md": "# Fixture\n" }, }, config: { autoupdate: false, username: "Drive", }, tuiConfig: { theme: { name: "opencode", mode: "dark" }, scroll: { speed: 1 }, }, setup: ({ fs, config, tuiConfig }) => Effect.gen(function* () { yield* fs.writeFile("src/setup.ts", "export const ready = true\n") config.username = "Setup wins" tuiConfig.scroll = { speed: 2 } }), }, ({ ui }) => ui.screenshot("home"), ) ``` The DSL is applied in this order: 1. `project.name` (default `files`) names the isolated project directory, shown as `~/<name>` in OpenCode; `project.files` is written into it. Read the path from `directory` on the driver or script context. 2. `config` and `tuiConfig` are deeply merged over `.opencode/opencode.jsonc` and `.opencode/cli.json` fixture values. Objects merge recursively; arrays and scalar values replace existing values. 3. `setup` runs and may write project files or mutate the merged `config` and `tuiConfig` objects. Its mutations take final precedence. 4. Drive writes both configs as stable, formatted JSON. With `project.git: true`, it creates a repository and commits the complete pre-launch state with fixed Git identity and timestamps. To show a realistic model in screenshots or recordings, rename the `simulation` provider and add a model under `config.providers.simulation.models`, then select it with `config.model` (for example `simulation/claude-opus-5-5`). Drive's LLM controller answers every model of that provider. `fs.writeFile` is rooted inside the simulated project and creates parent directories. `project.git: true` refuses to replace existing Git metadata; omit it when prepared fixtures already include a repository. `tuiConfig` contains current V2 CLI settings despite retaining its public option name. Drive pins `OPENCODE_CONFIG_DIR` to the isolated project's `.opencode`, so `cli.json` is the fixture's global terminal configuration. Do not write legacy `tui.jsonc`, or write `cli.json` manually in `setup` instead of mutating `tuiConfig`: the normalized object is written after setup finishes. ### UI And LLM UI operations are Effects: - `ui.submit(text)` types and presses Enter. - `ui.state()`, `ui.capture()`, and `ui.matches(text)` inspect the terminal. `ui.capture()` returns the raw terminal frame as data. - `ui.snapshot()` returns the versioned semantic tree; `ui.getNode(query, options?)` polls for one exact semantic match. - `ui.waitFor(textOrPredicate, options?)` polls until a match. - `ui.getElement(query, options?)`, `ui.focus(...)`, and `ui.click(...)` target interactive elements. - `ui.screenshot(name?)` asks OpenCode for the raw frame, saves it as a PNG inside Drive, and returns its absolute path. It requires no media-directory configuration. - `ui.resize({ cols, rows })`, `ui.press(...)`, and `ui.arrow(...)` control the TUI. - `ui.mouse({ action: "move", x, y })` sends real mouse movement at absolute, zero-based terminal cell coordinates. `down`/`up` accept `button` (`left` by default, `middle`, `right`); moves with a held button drag. `scroll` requires `direction`. Mouse modifiers are `{ shift, alt, ctrl }`. Older endpoints without `ui.mouse` fail with `UiCapabilityError`. Build deterministic simulated responses with the `Llm` namespace and schedule them through the driver's `llm` controller: ```ts yield* llm.queue( Llm.reasoning("Checking the fixture"), Llm.pause(20), Llm.text("The value is 1.", { delay: 2, chunkSize: 15 }), ) ``` `llm.queue(...)` declares the next response without waiting. `llm.send(...)` waits for the next request and completes its response. For ongoing responses, the handler passed to `llm.serve` returns an Effect `Stream`; registering the handler is an Effect. Available outputs include `text`, `reasoning`, `pause`, `toolCall`, `raw`, `finish`, and `disconnect`; a normal response gets `finish("stop")` when no terminal output is supplied. ```ts import { Stream } from "effect" import { Llm } from "opencode-drive" yield* llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)), ) ``` Use the capability names literally: `opencode` is the generated OpenCode SDK, `tui` is the primary frontend process, `ui` is `tui.ui`, and `tuis` launches additional frontend processes. `environment` holds the variables that point any other process, such as `opencode run` or an SDK script, at the isolated OpenCode; spread it over `process.env` and use `directory` as the working directory. Additional TUIs share the server and LLM controller: ```ts const secondary = yield* tuis.launch({ viewport: { cols: 120, rows: 40 }, recording: true, }) yield* secondary.ui.screenshot("secondary") ``` ### Annotated Recordings Set `tui: { recording: true, pointerOverlay: true }` for a minimal animated mouse cursor in exported videos. A configuration object accepts `leadMs` (180), `lingerMs` (700), `motionMs` (500), and `curve` (0.06). Travel uses a critically damped Motion spring, arriving exactly at input time along a bounded arc. Use `curve: 0` for straight travel; held drags never add decorative curvature. Nearby inputs stay connected. This needs an OpenCode build advertising `ui.recording.pointer`; input timestamps and actual click coordinates come from its recording clock, not Drive-side estimates. Keep the `*.pointers.jsonl` sidecar with the terminal recording when copying it. `exportRecording` accepts the same `pointerOverlay` option. The cursor animation never sends input or delays the script. To exercise hover along a path, send real intermediate `ui.mouse` moves. Use state-based waits for the hover result. Clips crop the already-composited raw animation (including approaches to later inputs); holds freeze both terminal pixels and the pointer. Visibility windows are bounded by retained recording time, not extra video time. With `tui: { recording: true }`, label moments during the run and the exported MP4 gets a burned-in footer (segment label bottom-left, elapsed timecode and "drive" branding bottom-right): ```ts yield* tui.recording.mark("typing a prompt — 600ms wire latency") // ... drive the UI ... yield* tui.recording.mark("") // clears the label const video = yield* tui.recording.finish() ``` For programmatic exports, `exportRecording` accepts `annotations` (labelled raw-timeline instants), `clips` (`{ fromMs, toMs, speed?, holdMs?, label? }` segments that trim/re-speed/freeze, concatenated in order), and `footer` (`false` suppresses it; `{ brand }` overrides the branding). See `test/manual/tui-regressions/optimistic-create-demo.ts` for a complete before/after demo recording script. #### Pull Request Before/After For a visible OpenCode change, default to a matched A/B recording rather than an after-only demo: 1. Write one deterministic Drive script and fixture. A temporary script is fine; do not fork separate before and after scripts. 2. Create an immutable worktree for the PR base and run the script against it with `--dev`, then run the same script against the change worktree. Keep the viewport, project files, config, simulated LLM output, and interactions identical. Parameterize only the annotation label when needed. 3. Use state-based waits to reach the comparison checkpoint. After transient overlays settle, call `tui.recording.mark("BEFORE: …")` or `tui.recording.mark("AFTER: …")` and hold each stable state for the same duration. 4. Trim from the marked checkpoints and concatenate equal-length clips with a shared timeline. Default to side-by-side playback so reviewers can compare both states simultaneously. Preserve dimensions and synchronize meaningful interactions; use a sequential hard cut only when side-by-side rendering makes terminal text illegible. 5. Inspect a frame from each segment before upload. The demo is complete only when both labels are visible, the regression is obvious in the before segment, the fix is obvious in the after segment, and the PR caption states which revision each segment runs plus any simulated behavior. ### Network Chaos Set `network: true` in `defineScript` to route every TUI through a chaos TCP proxy (the TUI is pinned with `--server`, so reconnects always cross the proxy). The Drive control plane and the `opencode` SDK stay clean; only the TUI's HTTP and SSE traffic degrades. ```ts export default defineScript({ network: true, run: ({ ui, network }) => Effect.gen(function* () { yield* network.set({ latencyMs: 400, jitterMs: 200 }) yield* network.set({ blackhole: true }) // buffer all bytes until clear yield* network.set({ refuseNew: true }) // refuse new connections yield* network.killConnections() // drop every open connection yield* network.clear() // heal; buffered bytes flush }), }) ``` A quiet blackhole does not raise the TUI's reconnect overlay; that needs a dropped connection (`killConnections`) while traffic is pending. ### Script-Writing Rules Of Thumb - Timeout severity is split: `UiWaitTimeoutError` (a `waitFor`/`getElement`/ `getNode` deadline passed) is catchable — branch on "did X appear in time?" with `Effect.catchTag("UiWaitTimeoutError", ...)`. `UiTimeoutError` (an unanswered UI RPC) aborts the whole run even when caught. - LLM request bodies carry the entire conversation. Route reply markers by which appears **last** in the serialized body (`lastIndexOf`), never by the first `includes` hit. - The server projection (`opencode.message.list`) is the ground truth for whether a prompt landed. The screen and the instance's `prompt-history.jsonl` can both mislead. - `ctrl+c` on an empty composer exits the TUI and kills the run (`RpcClientDefect: connection closed`); use `ctrl+u` to clear leftover composer text. - Effect v4: it is `Effect.catch`, not `Effect.catchAll`. - Isolated-instance state lives under `$artifacts/home/...`; screenshots land under the run's `output/.../generation-N/` directory. - Set `OPENCODE_DRIVE_MEDIA_DIR=$PWD/.drive-output` (gitignored) when running probes from an agent: the default media root sits under the system tmpdir with a per-run id in the path, so every run triggers a fresh outside-the-project permission prompt. - Interrupting a busy session takes **two** escape presses within 5 seconds (the first arms, the second fires `session.interrupt`). A single `ui.press("escape")` is a no-op for interruption. - `server.kill()` mid-stream abandons the in-flight served reply (a deliberate detach, not a run failure); `server.launch()` attaches the LLM stub to the replacement service. Pass `OPENCODE_DRIVE_DB=...` so both generations share a database. Scripted TUIs always use an explicit connection to the script-owned server (or the chaos proxy). HTTP address and existing credentials stay stable across server generations, so retained TUIs and SDK clients can reconnect without electing a competing service. After `server.kill()`, call `server.launch()`; do not wait for the TUI to create a server on the script's behalf. This does not change non-scripted live launches' managed-service behavior. ### Reports And Paths `OpenCodeDriver` exports a branded `AbsolutePath` schema and a compact `RunReport` containing the artifact root, retention, recording paths, and endpoint compatibility. It also exports `decodeAbsolutePath` and `decodeRunReport` for validating unknown values. `driver.settle()` returns the report with its recording paths. Use `OpenCodeDriver.useReport(options, run)` when a safe lifecycle program also needs the report alongside its result. Drive prefers protocol negotiation and reports explicit legacy fallback. Set `opencode.compatibility` to `"required"` when protocol skew must fail before the program runs. Static adapters are available for `shell`, `webfetch`, `websearch`, and `write`; use dynamic tools for everything else. ### Arbitrary Dynamic Tools Use runtime `tools.attach` for tools that do not have a shipped Drive adapter. Attachment replaces the complete dynamic set without affecting configured static adapters. Take invocations by the model call ID, while Drive owns producer IDs, progress sequences, reconnect replay, and exactly-one terminal commitment. ```ts yield* tools.attach({ tools: [ { name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"], }, options: { codemode: false }, }, ], }) const lookup = yield* tools.take("call_lookup") yield* lookup.progress({ phase: "searching" }) yield* lookup.finish({ structured: { answer: 42 }, content: [{ type: "text", text: "42" }], }) ``` Use `awaitCancelled()` to observe native interruption. Do not synthesize cancellation or expose transport sequence numbers. Dynamic effective names may not collide with configured `shell`, `webfetch`, `websearch`, or `write` adapters. ### Simulated Shell Execution Declare the built-in tools Drive should intercept, then control each invocation from the running program. Unregistered tools remain real. Calls may be accepted
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub