opencode-drive 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,90 +1,119 @@
1
1
  # opencode-drive
2
2
 
3
- Drive visible, headless, simulated, or real OpenCode instances.
3
+ Drive real and simulated OpenCode instances through fixed local WebSocket ports.
4
4
 
5
- ```bash
6
- bunx opencode-drive start --name demo
5
+ ## Skill
7
6
 
8
- bunx opencode-drive send --name demo \
9
- --command.type "hello" \
10
- --command.press enter \
11
- --command.render \
12
- --command.state
7
+ ```sh
8
+ npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
13
9
  ```
14
10
 
15
- `start` launches a detached headless simulated OpenCode process. Add
16
- `--visible` to keep it in the foreground and show it in the terminal. Pass a custom OpenCode command after
17
- `--`:
11
+ # Usage
12
+
13
+ **Start the default headless instance:**
18
14
 
19
15
  ```bash
20
- bunx opencode-drive start --name local --visible -- \
21
- opencode2 --standalone
16
+ bunx opencode-drive start
22
17
  ```
23
18
 
24
- Use `--dev` to run an OpenCode development checkout. The launcher installs the
25
- checkout's `@opentui/solid` runtime in its isolated working directory and configures
26
- Bun automatically:
19
+ There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
27
20
 
28
- ```bash
29
- bunx opencode-drive start --visible --dev ~/projects/opencode-latest
21
+ Run a local OpenCode checkout:
22
+
23
+ ```sh
24
+ bunx opencode-drive start --dev ~/projects/opencode
25
+ ```
26
+
27
+ ## Send UI Commands
28
+
29
+ `send` always connects to UI port `40900`:
30
+
31
+ ```sh
32
+ bunx opencode-drive send \
33
+ --command.ui.type '{"text":"Explain this project"}' \
34
+ --command.ui.enter
35
+
36
+ bunx opencode-drive send --command.ui.state
37
+ bunx opencode-drive send --command.ui.screenshot
30
38
  ```
31
39
 
32
- Both `start` and `send` accept `--driver ./driver.ts`. Drivers may default
33
- export a function created with `defineDriver`:
40
+ Run `bunx opencode-drive api` for the full UI command contract.
41
+
42
+ ## Scripted Mode
43
+
44
+ Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
45
+
46
+ ```sh
47
+ bunx opencode-drive start --script ./drive.ts
48
+ ```
49
+
50
+ Add `--visible` to watch the TUI while the script runs. Visible starts remain active after a script finishes. Restart the visible OpenCode child and rerun its script with `bunx opencode-drive restart`.
51
+
52
+ Only one visible instance can be controlled this way. Headless runs exit when their script completes and do not support restart.
53
+
54
+ The module must default-export a function receiving connected clients and the run's artifact directory:
34
55
 
35
56
  ```ts
36
- import { defineDriver } from "opencode-drive/experimental"
57
+ import { defineScript } from "opencode-drive"
37
58
 
38
- export default defineDriver(async ({ ui }) => {
39
- await ui.typeText("hello")
59
+ export default defineScript(async ({ ui, backend }) => {
60
+ await backend.attach(async (request) => {
61
+ await backend.chunk(request.id, [{ type: "textDelta", text: "Hello" }])
62
+ await backend.finish(request.id)
63
+ })
64
+
65
+ await ui.typeText("Say hello")
40
66
  await ui.pressEnter()
41
- const state = await ui.state()
42
- if (!state.focused.editor) throw new Error("prompt editor is not focused")
67
+ await ui.screenshot()
43
68
  })
44
69
  ```
45
70
 
46
- Experimental campaign modules use `defineCampaign` from `opencode-drive/experimental`. Every case gets a fresh isolated,
47
- headless OpenCode process. One deterministic case can use the same runner in a
48
- visible terminal:
71
+ # Use cases
49
72
 
50
- ```bash
51
- bunx opencode-drive start --campaign ./campaign.ts --seed 42000
52
- bunx opencode-drive start --campaign ./campaign.ts --seed 42000 --case 17 --visible
53
- ```
73
+ There are two different modes OpenCode can run in:
74
+
75
+ * Simulated: core layers like networking are swapped out in the backend so you can control them
76
+ * Driven: starts websocket servers internally to expose commands to drive the app
77
+
78
+ You can choose one or the other, or both! This allows the following use cases:
79
+
80
+ ## You want to develop opencode
81
+
82
+ You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
54
83
 
55
- OpenCode starts its drive interfaces when `OPENCODE_DRIVE` contains an instance
56
- name and its simulated services when `OPENCODE_SIMULATE=1`. `run` creates the
57
- named registry manifest, then sets both variables. OpenCode resolves the
58
- manifest by name to obtain its drive ports. The manifest has this contract:
59
-
60
- ```json
61
- {
62
- "version": 1,
63
- "name": "demo",
64
- "pid": 1234,
65
- "startedAt": "2026-07-06T00:00:00.000Z",
66
- "mode": "simulated",
67
- "cwd": "/workspace",
68
- "artifacts": "/tmp/opencode-drive/demo",
69
- "endpoints": {
70
- "ui": "ws://127.0.0.1:41000",
71
- "backend": "ws://127.0.0.1:41001"
72
- }
73
- }
84
+ In this case, you don't care about simulation. But you still want OpenCode to see and drive your development version! This _closes the loop_ and gives direct feedback to AI.
85
+
86
+ To do this, all you have to do is pass `OPENCODE_DRIVE=1` when running opencode:
87
+
88
+ ```sh
89
+ # In the opencode repo
90
+ OPENCODE_DRIVE=1 bun run dev
74
91
  ```
75
92
 
76
- ## Probe Experiments
93
+ #### UI work in simulated mode
77
94
 
78
- Model-based and deterministic simulation drivers for the local opencode V2 TUI
79
- and server. The probe controls the real application through simulation-only
80
- WebSocket interfaces while external model and filesystem state remain isolated.
95
+ If you are doing UI work, you may want to run it in simulated mode. You can do that with this:
81
96
 
82
- The opencode checkout is expected at `~/projects/opencode-latest`.
97
+ ```
98
+ bunx opencode-drive start --dev . --visible
99
+ ```
83
100
 
84
- ## Setup
101
+ The nice thing about this is OpenCode can drive it into the states you are interested in. Additionally, its able to restart the UI itself. The server will still be running in the background so this only restarts the UI:
85
102
 
86
- ```bash
87
- cd ~/projects/opencode-drive
88
- bun install
89
- bun run check
103
+ ```sh
104
+ bunx opencode-drive restart
90
105
  ```
106
+
107
+ ## You want opencode to develop itself
108
+
109
+ OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
110
+
111
+ It will spawn it in both drive and simulated mode, allowing it explore the app. It will use the `screenshot` command to capture screenshots to see what's happening, providing a full feedback loop.
112
+
113
+ ## You want to share reproducible steps
114
+
115
+ Ask opencode to find a bug, and list the commands it used to get there. You can then share this list with someone else who can use `opencode-drive` in visible mode to visually inspect it and develop a fix. (Or just share the steps with your own local OpenCode to find a fix)
116
+
117
+ ## You want to run it a billion times and assert properties
118
+
119
+ This isn't fully implemented right now, but in the future `opencode-drive` will provide a way to specify properties that you want to assert, and run the app a billion times in many different states to make sure those properties hold. This will work with a new CLI command that takes different flags.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-drive",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "description": "Drive real and simulated OpenCode instances",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "packageManager": "bun@1.3.14",
9
9
  "bin": {
10
- "opencode-drive": "./bin/opencode-drive"
10
+ "opencode-drive": "bin/opencode-drive"
11
11
  },
12
12
  "exports": {
13
13
  ".": "./src/index.ts",
package/src/cli/api.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { resolve } from "node:path"
2
+
3
+ export async function api() {
4
+ process.stdout.write(await Bun.file(resolve(import.meta.dir, "..", "client", "protocol.types.ts")).text())
5
+ }
@@ -1,64 +1,76 @@
1
- import type { BackendFinishReason, BackendItem, KeyModifiers } from "../client/index.js"
2
- import { connectBackendSimulation, connectSimulation } from "../client/index.js"
3
- import type { DriveCommand, InstanceManifest } from "./types.js"
4
-
5
- const noValue = new Set([
6
- "render",
7
- "state",
8
- "start-record",
9
- "end-record",
10
- "enter",
11
- "llm.pending",
12
- "network.log",
13
- ])
14
-
15
- const withValue = new Set([
16
- "type",
17
- "press",
18
- "arrow",
19
- "focus",
20
- "click",
21
- "llm.respond",
22
- "llm.chunk",
23
- "llm.finish",
24
- "llm.disconnect",
25
- ])
1
+ import { connectSimulation, Frontend } from "../client/index.js"
2
+ import type { DriveCommand } from "./types.js"
3
+
4
+ export const commandInfo = {
5
+ "ui.type": { value: true, description: "Type text using JSON params" },
6
+ "ui.press": { value: true, description: "Press a key using JSON params" },
7
+ "ui.enter": { value: false, description: "Press Enter" },
8
+ "ui.arrow": {
9
+ value: true,
10
+ description: "Press an arrow key using JSON params",
11
+ },
12
+ "ui.focus": {
13
+ value: true,
14
+ description: "Focus an element using JSON params",
15
+ },
16
+ "ui.click": { value: true, description: "Click using JSON params" },
17
+ "ui.screenshot": {
18
+ value: false,
19
+ description: "Take a screenshot and return its path",
20
+ },
21
+ "ui.state": {
22
+ value: false,
23
+ description: "Return focus, elements, and available UI actions",
24
+ },
25
+ "ui.start-record": { value: false, description: "Start recording the UI" },
26
+ "ui.end-record": {
27
+ value: false,
28
+ description: "Stop recording and return the recording path",
29
+ },
30
+ } as const
26
31
 
27
32
  export function commandAcceptsValue(operation: string) {
28
- if (noValue.has(operation)) return false
29
- if (withValue.has(operation)) return true
33
+ if (operation === "ui.type") return commandInfo[operation].value
34
+ if (operation === "ui.press") return commandInfo[operation].value
35
+ if (operation === "ui.enter") return commandInfo[operation].value
36
+ if (operation === "ui.arrow") return commandInfo[operation].value
37
+ if (operation === "ui.focus") return commandInfo[operation].value
38
+ if (operation === "ui.click") return commandInfo[operation].value
39
+ if (operation === "ui.screenshot") return commandInfo[operation].value
40
+ if (operation === "ui.state") return commandInfo[operation].value
41
+ if (operation === "ui.start-record") return commandInfo[operation].value
42
+ if (operation === "ui.end-record") return commandInfo[operation].value
30
43
  throw new Error(`unknown drive command "${operation}"`)
31
44
  }
32
45
 
33
46
  export function commandNames() {
34
- return [...noValue, ...withValue].sort()
47
+ return Object.keys(commandInfo).sort()
35
48
  }
36
49
 
37
- export async function executeCommands(manifest: InstanceManifest, commands: ReadonlyArray<DriveCommand>) {
38
- const clients: {
39
- ui?: Awaited<ReturnType<typeof connectSimulation>>
40
- backend?: Awaited<ReturnType<typeof connectBackendSimulation>>
41
- } = {}
42
- const ui = async () => (clients.ui ??= await connectSimulation({ url: manifest.endpoints.ui }))
43
- const backend = async () => (clients.backend ??= await connectBackendSimulation({ url: manifest.endpoints.backend }))
44
- const results: Array<{ readonly command: string; readonly result: unknown }> = []
50
+ export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
51
+ const ui = await connectSimulation({ url: endpoint })
52
+ const results: Array<{ readonly command: string; readonly result: unknown }> =
53
+ []
45
54
  try {
46
- for (const command of commands) {
47
- results.push({ command: command.operation, result: await execute(command, ui, backend) })
48
- }
49
- return { name: manifest.name, results }
55
+ for (const command of commands)
56
+ results.push({
57
+ command: command.operation,
58
+ result: await execute(command, ui),
59
+ })
60
+ return { results }
50
61
  } catch (error) {
51
- throw new CommandBatchError(manifest.name, results, error)
62
+ throw new CommandBatchError(results, error)
52
63
  } finally {
53
- clients.ui?.close()
54
- clients.backend?.close()
64
+ ui.close()
55
65
  }
56
66
  }
57
67
 
58
68
  export class CommandBatchError extends Error {
59
69
  constructor(
60
- readonly instance: string,
61
- readonly results: ReadonlyArray<{ readonly command: string; readonly result: unknown }>,
70
+ readonly results: ReadonlyArray<{
71
+ readonly command: string
72
+ readonly result: unknown
73
+ }>,
62
74
  readonly reason: unknown,
63
75
  ) {
64
76
  super(reason instanceof Error ? reason.message : String(reason))
@@ -68,108 +80,79 @@ export class CommandBatchError extends Error {
68
80
 
69
81
  async function execute(
70
82
  command: DriveCommand,
71
- ui: () => Promise<Awaited<ReturnType<typeof connectSimulation>>>,
72
- backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
83
+ ui: Awaited<ReturnType<typeof connectSimulation>>,
73
84
  ) {
74
85
  switch (command.operation) {
75
- case "render": return (await ui()).render()
76
- case "state": return (await ui()).state()
77
- case "start-record": return (await ui()).startRecord()
78
- case "end-record": return (await ui()).endRecord()
79
- case "type": return (await ui()).typeText(required(command))
80
- case "press": {
81
- const value = required(command)
82
- if (!value.trim().startsWith("{")) return value === "enter" ? (await ui()).pressEnter() : (await ui()).pressKey(value)
83
- const input = object(value)
84
- return (await ui()).pressKey(string(input, "key"), modifiers(input.modifiers))
86
+ case "ui.type": {
87
+ const request = Frontend.decodeRequest({
88
+ jsonrpc: "2.0",
89
+ method: "ui.type",
90
+ params: json(required(command)),
91
+ })
92
+ if (request.method !== "ui.type")
93
+ throw new Error("invalid ui.type params")
94
+ return ui.typeText(request.params.text)
85
95
  }
86
- case "enter": return (await ui()).pressEnter()
87
- case "arrow": {
88
- const direction = required(command)
89
- if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
90
- throw new Error("arrow must be up, down, left, or right")
91
- }
92
- return (await ui()).pressArrow(direction)
96
+ case "ui.press": {
97
+ const request = Frontend.decodeRequest({
98
+ jsonrpc: "2.0",
99
+ method: "ui.press",
100
+ params: json(required(command)),
101
+ })
102
+ if (request.method !== "ui.press")
103
+ throw new Error("invalid ui.press params")
104
+ return ui.pressKey(request.params.key, request.params.modifiers)
93
105
  }
94
- case "focus": return (await ui()).focus(number(required(command), "target"))
95
- case "click": {
96
- const input = object(required(command))
97
- return (await ui()).click(number(input.target, "target"), number(input.x, "x"), number(input.y, "y"))
106
+ case "ui.enter":
107
+ return ui.pressEnter()
108
+ case "ui.arrow": {
109
+ const request = Frontend.decodeRequest({
110
+ jsonrpc: "2.0",
111
+ method: "ui.arrow",
112
+ params: json(required(command)),
113
+ })
114
+ if (request.method !== "ui.arrow")
115
+ throw new Error("invalid ui.arrow params")
116
+ return ui.pressArrow(request.params.direction)
98
117
  }
99
- case "llm.pending": return (await backend()).pendingExchanges()
100
- case "llm.respond": {
101
- const input = object(required(command))
102
- const client = await backend()
103
- const chunk = await client.chunk(string(input, "id"), [{ type: "textDelta", text: string(input, "text") }])
104
- const finish = await client.finish(string(input, "id"), finishReason(input.reason))
105
- return { chunk, finish }
118
+ case "ui.focus": {
119
+ const request = Frontend.decodeRequest({
120
+ jsonrpc: "2.0",
121
+ method: "ui.focus",
122
+ params: json(required(command)),
123
+ })
124
+ if (request.method !== "ui.focus")
125
+ throw new Error("invalid ui.focus params")
126
+ return ui.focus(request.params.target)
106
127
  }
107
- case "llm.chunk": {
108
- const input = object(required(command))
109
- if (!Array.isArray(input.items)) throw new Error("llm.chunk items must be an array")
110
- return (await backend()).chunk(string(input, "id"), input.items as ReadonlyArray<BackendItem>)
128
+ case "ui.click": {
129
+ const request = Frontend.decodeRequest({
130
+ jsonrpc: "2.0",
131
+ method: "ui.click",
132
+ params: json(required(command)),
133
+ })
134
+ if (request.method !== "ui.click")
135
+ throw new Error("invalid ui.click params")
136
+ return ui.click(request.params.target, request.params.x, request.params.y)
111
137
  }
112
- case "llm.finish": {
113
- const input = scalarOrObject(required(command), "id")
114
- return (await backend()).finish(string(input, "id"), finishReason(input.reason))
115
- }
116
- case "llm.disconnect": return (await backend()).disconnect(required(command))
117
- case "network.log": return (await backend()).networkLog()
138
+ case "ui.screenshot":
139
+ return ui.screenshot()
140
+ case "ui.state":
141
+ return ui.state()
142
+ case "ui.start-record":
143
+ return ui.startRecord()
144
+ case "ui.end-record":
145
+ return ui.endRecord()
118
146
  }
119
147
  throw new Error(`unknown drive command "${command.operation}"`)
120
148
  }
121
149
 
122
150
  function required(command: DriveCommand) {
123
- if (command.value === undefined) throw new Error(`${command.operation} requires a value`)
151
+ if (command.value === undefined)
152
+ throw new Error(`${command.operation} requires a value`)
124
153
  return command.value
125
154
  }
126
155
 
127
- function object(value: string) {
128
- const parsed: unknown = JSON.parse(value)
129
- if (!isRecord(parsed)) throw new Error("command value must be a JSON object")
130
- return parsed
131
- }
132
-
133
- function scalarOrObject(value: string, key: string) {
134
- return value.trim().startsWith("{") ? object(value) : { [key]: value }
135
- }
136
-
137
- function string(input: Record<string, unknown>, key: string) {
138
- const value = input[key]
139
- if (typeof value !== "string") throw new Error(`${key} must be a string`)
140
- return value
141
- }
142
-
143
- function number(value: unknown, name: string) {
144
- const parsed = typeof value === "number" ? value : Number(value)
145
- if (!Number.isFinite(parsed)) throw new Error(`${name} must be a number`)
146
- return parsed
147
- }
148
-
149
- function modifiers(value: unknown): KeyModifiers | undefined {
150
- if (value === undefined) return undefined
151
- if (!isRecord(value)) throw new Error("modifiers must be an object")
152
- return {
153
- ctrl: optionalBoolean(value.ctrl, "ctrl"),
154
- shift: optionalBoolean(value.shift, "shift"),
155
- meta: optionalBoolean(value.meta, "meta"),
156
- super: optionalBoolean(value.super, "super"),
157
- hyper: optionalBoolean(value.hyper, "hyper"),
158
- }
159
- }
160
-
161
- function finishReason(value: unknown): BackendFinishReason | undefined {
162
- if (value === undefined) return undefined
163
- if (value === "stop" || value === "tool-calls" || value === "length" || value === "content-filter") return value
164
- throw new Error("reason must be stop, tool-calls, length, or content-filter")
165
- }
166
-
167
- function isRecord(value: unknown): value is Record<string, unknown> {
168
- return typeof value === "object" && value !== null && !Array.isArray(value)
169
- }
170
-
171
- function optionalBoolean(value: unknown, name: string) {
172
- if (value === undefined) return undefined
173
- if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`)
174
- return value
156
+ function json(value: string): unknown {
157
+ return JSON.parse(value)
175
158
  }
@@ -0,0 +1,43 @@
1
+ import { rm } from "node:fs/promises"
2
+ import { connect, createServer } from "node:net"
3
+
4
+ export async function listenControl(
5
+ path: string,
6
+ handlers: { readonly restart: () => Promise<void>; readonly stop: () => Promise<void> },
7
+ ) {
8
+ const server = createServer((socket) => {
9
+ socket.setEncoding("utf8")
10
+ socket.once("data", (data) => {
11
+ const handler = handlers[String(data).trim() as keyof typeof handlers]
12
+ socket.destroy()
13
+ if (!handler) return
14
+ void handler().catch((error) => {
15
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
16
+ })
17
+ })
18
+ })
19
+ await listen(server, path)
20
+ return async () => {
21
+ await new Promise<void>((resolve) => server.close(() => resolve()))
22
+ await rm(path, { force: true })
23
+ }
24
+ }
25
+
26
+ export function request(path: string, command: "restart" | "stop") {
27
+ return new Promise<void>((resolve, reject) => {
28
+ const socket = connect(path)
29
+ socket.on("connect", () => socket.end(`${command}\n`, resolve))
30
+ socket.on("error", () => reject(new Error("instance control socket is unavailable")))
31
+ })
32
+ }
33
+
34
+ async function listen(server: ReturnType<typeof createServer>, path: string) {
35
+ await rm(path, { force: true })
36
+ await new Promise<void>((resolve, reject) => {
37
+ server.once("error", reject)
38
+ server.listen(path, () => {
39
+ server.off("error", reject)
40
+ resolve()
41
+ })
42
+ })
43
+ }
@@ -1,14 +1,13 @@
1
- import { join } from "node:path"
2
1
  import { resolveInstance } from "./registry.js"
3
2
 
4
- export async function describe(name?: string) {
5
- const manifest = await resolveInstance(name ?? "default")
3
+ export async function describe(name: string) {
4
+ const manifest = await resolveInstance(name)
6
5
  console.log([
7
6
  `PID: ${manifest.pid}`,
8
- `Headless: ${manifest.headless}`,
7
+ `Visible: ${manifest.visible}`,
9
8
  `Artifacts: ${manifest.artifacts}`,
10
9
  `UI: ${manifest.endpoints.ui}`,
11
10
  `Backend: ${manifest.endpoints.backend}`,
12
- `Logs: ${join(manifest.artifacts, "home", ".local", "share", "opencode", "log", "opencode*.log")}`,
11
+ `Logs: ${manifest.artifacts}/logs`,
13
12
  ].join("\n"))
14
13
  }