用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/anomalyco/opencode-drive --skill opencode-drive命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
基于 SOC 职业分类
正在显示 SKILL.md
| name | opencode-drive |
| description | Use when an agent needs to drive OpenCode with an Effect program or interact with an isolated instance |
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.
Browse and copy OpenCode terminal state IDs from:
https://dev.opencode.ai/lab/catalog
Replayable flow states expose canonical <flow-id>/<state-id> addresses, for example:
patch-success-lifecycle/permission-prompt
Reproduce one from an opencode-drive source checkout:
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:
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.
Write drive.ts as a default-exported, fully provided Effect, then run it directly:
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")
}),
)
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.
Declare the project, semantic OpenCode configuration, and TUI configuration in OpenCodeDriver.use options:
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:
project.files is written into the isolated project.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.setup runs and may write project files or mutate the merged config and tuiConfig objects. Its mutations take final precedence.project.git: true, it creates a repository and commits the complete pre-launch state with fixed Git identity and timestamps.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 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.Build deterministic simulated responses with the Llm namespace and schedule them through the driver's llm controller:
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.
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.
Additional TUIs share the server and LLM controller:
const secondary = yield* tuis.launch({
viewport: { cols: 120, rows: 40 },
recording: true,
})
yield* secondary.ui.screenshot("secondary")
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):
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.
For a visible OpenCode change, default to a matched A/B recording rather than an after-only demo:
--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.tui.recording.mark("BEFORE: …") or
tui.recording.mark("AFTER: …") and hold each stable state for the same
duration.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.
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.
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.lastIndexOf), never by the
first includes hit.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.catch, not Effect.catchAll.$artifacts/home/...; screenshots land
under the run's output/.../generation-N/ directory.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.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.
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. Additional built-in tool adapters remain follow-ups.
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.
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, or websearch adapters.
Declare the built-in tools Drive should intercept, then control each invocation
from the running program. Unregistered tools remain real. Calls may be accepted
in arrival order or by the stable ID supplied in Llm.toolCall, so parallel
invocations can progress and settle independently.
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use({ tools: ["shell"] }, ({ tools, llm, ui }) =>
Effect.gen(function* () {
const shells = yield* tools.control("shell")
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "call_shell",
name: "shell",
input: { command: "compile" },
}),
Llm.finish("tool-calls"),
)
yield* ui.submit("Compile the project")
const shell = yield* shells.take("call_shell")
yield* shell.progress(`Running ${shell.input.command}\n`)
yield* shell.({ : , : })
}),
)
The same declaration and runtime tools capability are available in
defineScript. Supported adapters are shell, webfetch, and websearch.
Each progress value replaces the visible tool output, so send accumulated text
when earlier lines should remain visible. Calls settle exactly once;
awaitInterrupted() observes session interruption or transport disconnection.
The callback form remains available for fixed behavior that does not need runtime orchestration:
import { Effect } from "effect"
import { Tool } from "opencode-drive"
const tools = (registry: Tool.Registry) => {
registry.handle("shell", ({ input, progress }) =>
Effect.gen(function* () {
yield* progress(`Running ${input.command}\n`)
return { output: "Controlled success\n", exit: 0 }
}),
)
}
Foreground callback handler Effects are interrupted when OpenCode interrupts the session, the transport disconnects, or Drive shuts down. Detached background shell handlers continue after their launch response and are interrupted when Drive shuts down.
Use defineScript with start --script when the workflow must have a stable
instance name, be visible, rerun on restart, or explicitly launch and kill
its server and TUIs. setup and run return Effects. Operations on fs,
ui, llm, tools, server, and tuis also return Effects; there is no
Promise API or compatibility shim.
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
config: { autoupdate: false },
tuiConfig: { theme: { name: "opencode", mode: "dark" } },
project: {
git: true,
files: { "src/value.ts": "export const value = 1\n" },
},
run: ({ 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.")
}),
})
Always type-check a script before starting it:
opencode-drive check ./drive.ts
opencode-drive start --name demo --script ./drive.ts
For a new script, run opencode-drive script init ./drive.ts once. It creates a
canonical Effect-native starter and refuses to overwrite an existing file.
check adds focused migration guidance when it finds Promise-style script
callbacks.
The script DSL applies project, config, tuiConfig, and setup with the same deterministic ordering described above. Automatic scripts run again after opencode-drive restart --name demo.
Use launch: "manual" only when the workflow must control server and TUI restarts itself. In manual mode tui and ui are null; run server.launch() before tuis.launch(name). Only one server may run at a time, server.kill() permits relaunch, and a closed TUI name may be reused.
export default defineScript({
launch: "manual",
run: ({ server, tuis }) =>
Effect.gen(function* () {
yield* server.launch()
const alice = yield* tuis.launch("alice", { recording: true })
yield* alice.ui.screenshot("alice")
yield* alice.close()
}),
})
Cancellation uses Effect interruption. Interrupting the script or an
operation's fiber interrupts in-flight work and runs scoped finalizers; do not
introduce AbortSignal or Promise cancellation wrappers.
Use init only when files must be copied into an isolated home or project before a named live or scripted instance starts:
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. A later start --name demo reuses the prepared artifacts; otherwise start initializes them automatically.
Drive uses an in-memory OpenCode database by default. For a script that restarts
the OpenCode service and must recover the same sessions, set
OPENCODE_DRIVE_DB to a file-backed path. Relative paths resolve inside the
isolated run's OpenCode data directory:
OPENCODE_DRIVE_DB=restart.sqlite \
opencode-drive start --name restart-demo --script ./restart.ts
Use live commands to inspect or iterate on a persistent instance. Headless start requires a unique --name; visible instances may omit it. Headless start detaches after the instance is ready, so do not add &. 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 send --name demo --command.ui.state
opencode-drive send --name demo --command.ui.capture
opencode-drive send --name demo --command.ui.screenshot
opencode-drive stop --name demo
Image commands have one simple distinction:
--command.ui.capture prints the raw terminal frame as JSON.--command.ui.screenshot saves that frame as a PNG and prints its absolute path.Drive chooses the PNG directory. Do not configure a media directory or expect the OpenCode endpoint to provide one. Run --command.ui.screenshot as its own send invocation when the path is needed on stdout; a multi-command batch reports only whether the batch succeeded.
send executes command flags from left to right. JSON-valued commands take one JSON argument. Supported commands are:
--command.ui.type '{"text":"..."}'--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'--command.ui.enter--command.ui.arrow '{"direction":"down"}'--command.ui.focus '{"target":12}'--command.ui.click '{"target":12,"x":4,"y":1}'--command.ui.resize '{"cols":120,"rows":40}'--command.ui.screenshot or --command.ui.screenshot '{"name":"home"}'--command.ui.state--command.ui.snapshot--command.ui.capture--command.ui.matches '{"text":"OpenCode"}'--command.ui.recording.finishUse meta for the terminal Alt modifier. For example:
--command.ui.press '{"key":"down","modifiers":{"meta":true}}'.
Start with --record to record a headless live instance. stop finishes the recording, exports the MP4, performs owner cleanup, and prints the path.
Add --keypress-overlay when the video should show KeyCastr-style pills for
the agent's semantic hotkeys, Enter presses, and arrow navigation. It requires
--record; batched typed text is deliberately omitted.
opencode-drive start --name demo --record --keypress-overlay
opencode-drive stop --name demo
dir prints a live instance's artifact directory, and list lists active instances:
opencode-drive dir --name demo
opencode-drive list
Drive is a work in progress. While using it, file a GitHub issue on
anomalyco/opencode-drive for anything that surprises you or that you wish
existed; do not just work around it silently. Check gh issue list first and
comment on an existing issue instead of opening a duplicate.
Worth an issue:
press
string that typed literally instead of sending a key, a timeout with no
frame, a hang in stop or prune.@opencode-ai/* client that
cannot decode a current server response.Keep OpenCode bugs out of the Drive tracker. When Drive merely exposed a
server or TUI defect, note the Drive-side workaround in the probe or README
and report the defect in anomalyco/opencode (or Kit's todo tracker when a
public issue is premature).
gh issue create -R anomalyco/opencode-drive --label bug \
--title "press: string with '+' is typed literally instead of parsed as a chord" \
--body "Drive <version>. Script excerpt, expected vs actual, log path."
Include the Drive version, the OpenCode revision under test, the smallest script excerpt that shows it, expected versus actual, and the artifact or log path. Never include credentials or private project data.
prune removes inactive artifact directories. To remove one instance's artifacts, pass the instance name supplied to init or start, not the generated run-* artifact directory name:
opencode-drive prune --name demo
# Force removal of all artifact directories, including active ones.
opencode-drive prune --force