opencode-drive
Use when an agent needs to drive OpenCode with an Effect program or interact with an isolated instance
Informations de source
- Dépôt
- anomalyco/opencode-drive
- Dernière activité de la source
- 14 octobre 2026 à 09:27
- Langue détectée de SKILL.md
- anglais
- Étoiles
- 34
- Forks
- 6
Options d'installation
Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.
Vérifiez les fichiers source
Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.
Affichage de SKILL.md
SKILL.md
Instructions source · Aperçu en lecture seule- 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
Voir sur GitHubCe SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub