opencode-drive 0.1.1 → 0.1.2

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
@@ -2,89 +2,99 @@
2
2
 
3
3
  Drive visible, headless, simulated, or real OpenCode instances.
4
4
 
5
+ # Usage
6
+
7
+ **Start the default detached, headless instance:**
8
+
9
+ ```bash
10
+ bunx opencode-drive start
11
+ ```
12
+
13
+ **Start a named instance, then address it with `--name`:**
14
+
5
15
  ```bash
6
16
  bunx opencode-drive start --name demo
17
+ ```
18
+
19
+ **Type, submit a prompt, and take a screenshot:**
7
20
 
21
+ ```bash
8
22
  bunx opencode-drive send --name demo \
9
- --command.type "hello" \
10
- --command.press enter \
11
- --command.render \
12
- --command.state
23
+ --command.type "Explain this project in one sentence" \
24
+ --command.enter \
25
+ --command.screenshot
13
26
  ```
14
27
 
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
- `--`:
28
+ **List the full command API for agents:**
18
29
 
19
30
  ```bash
20
- bunx opencode-drive start --name local --visible -- \
21
- opencode2 --standalone
31
+ bunx opencode-drive api
22
32
  ```
23
33
 
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:
34
+ **Run OpenCode visibly in the foreground:**
27
35
 
28
36
  ```bash
29
- bunx opencode-drive start --visible --dev ~/projects/opencode-latest
37
+ bunx opencode-drive start --visible
30
38
  ```
31
39
 
32
- Both `start` and `send` accept `--driver ./driver.ts`. Drivers may default
33
- export a function created with `defineDriver`:
34
-
35
- ```ts
36
- import { defineDriver } from "opencode-drive/experimental"
40
+ **Run a local OpenCode development checkout:**
37
41
 
38
- export default defineDriver(async ({ ui }) => {
39
- await ui.typeText("hello")
40
- await ui.pressEnter()
41
- const state = await ui.state()
42
- if (!state.focused.editor) throw new Error("prompt editor is not focused")
43
- })
42
+ ```bash
43
+ cd ~/projects/opencode
44
+ bunx opencode-drive start --visible --dev .
44
45
  ```
45
46
 
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:
47
+ **Run a custom OpenCode command after `--`:**
49
48
 
50
49
  ```bash
51
- bunx opencode-drive start --campaign ./campaign.ts --seed 42000
52
- bunx opencode-drive start --campaign ./campaign.ts --seed 42000 --case 17 --visible
50
+ bunx opencode-drive start --name demo -- opencode2 --standalone
53
51
  ```
54
52
 
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
- }
53
+ **Stop a detached headless instance:**
54
+
55
+ ```bash
56
+ bunx opencode-drive stop --name demo
74
57
  ```
75
58
 
76
- ## Probe Experiments
59
+ # Use cases
77
60
 
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.
61
+ There are two different modes OpenCode can run in:
81
62
 
82
- The opencode checkout is expected at `~/projects/opencode-latest`.
63
+ * Simulated: core layers like networking are swapped out in the backend so you can control them
64
+ * Driven: starts websocket servers internally to expose commands to drive the app
83
65
 
84
- ## Setup
66
+ You can choose one or the other, or both! This allows the following use cases:
85
67
 
86
- ```bash
87
- cd ~/projects/opencode-drive
88
- bun install
89
- bun run check
68
+ 1. You want to develop opencode
69
+
70
+ You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
71
+
72
+ 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.
73
+
74
+ To do this, run it with a development version of OpenCode with `--dev` (takes the path to the code) and `--visible` (so you can see it):
75
+
76
+ ```sh
77
+ bunx opencode-drive start --dev . --visible
90
78
  ```
79
+
80
+ If you are doing UI work, you can use the `restart` command to refresh the UI. The server will still be running in the background so this only restarts the UI:
81
+
82
+ ```sh
83
+ bunx opencode-drive restart
84
+ ```
85
+
86
+ If you want to restart the backend, use the `/reload` command.
87
+
88
+ 2. You want opencode to develop itself
89
+
90
+ OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
91
+
92
+ 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.
93
+
94
+ 3. You want to share reproducible steps
95
+
96
+ 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)
97
+
98
+ 4. You want to run it a billion times and assert properties
99
+
100
+ 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.2",
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,37 +1,30 @@
1
- import type { BackendFinishReason, BackendItem, KeyModifiers } from "../client/index.js"
2
- import { connectBackendSimulation, connectSimulation } from "../client/index.js"
1
+ import { Backend, connectBackendSimulation, connectSimulation, Frontend } from "../client/index.js"
3
2
  import type { DriveCommand, InstanceManifest } from "./types.js"
4
3
 
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
- ])
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": { value: true, description: "Press an arrow key using JSON params" },
9
+ "ui.focus": { value: true, description: "Focus an element using JSON params" },
10
+ "ui.click": { value: true, description: "Click using JSON params" },
11
+ "ui.screenshot": { value: false, description: "Take a screenshot and return its path" },
12
+ "ui.state": { value: false, description: "Return focus, elements, and available UI actions" },
13
+ "ui.start-record": { value: false, description: "Start recording the UI" },
14
+ "ui.end-record": { value: false, description: "Stop recording and return the recording path" },
15
+ "llm.pending": { value: false, description: "List pending simulated LLM exchanges" },
16
+ "llm.chunk": { value: true, description: "Send response items to a simulated LLM exchange" },
17
+ "llm.finish": { value: true, description: "Finish a simulated LLM exchange" },
18
+ "llm.disconnect": { value: true, description: "Disconnect a simulated LLM exchange" },
19
+ } as const
26
20
 
27
21
  export function commandAcceptsValue(operation: string) {
28
- if (noValue.has(operation)) return false
29
- if (withValue.has(operation)) return true
22
+ if (operation in commandInfo) return commandInfo[operation as keyof typeof commandInfo].value
30
23
  throw new Error(`unknown drive command "${operation}"`)
31
24
  }
32
25
 
33
26
  export function commandNames() {
34
- return [...noValue, ...withValue].sort()
27
+ return Object.keys(commandInfo).sort()
35
28
  }
36
29
 
37
30
  export async function executeCommands(manifest: InstanceManifest, commands: ReadonlyArray<DriveCommand>) {
@@ -72,49 +65,52 @@ async function execute(
72
65
  backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
73
66
  ) {
74
67
  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))
68
+ case "ui.type": {
69
+ const request = Frontend.decodeRequest({ jsonrpc: "2.0", method: "ui.type", params: json(required(command)) })
70
+ if (request.method !== "ui.type") throw new Error("invalid ui.type params")
71
+ return (await ui()).typeText(request.params.text)
85
72
  }
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)
73
+ case "ui.press": {
74
+ const request = Frontend.decodeRequest({ jsonrpc: "2.0", method: "ui.press", params: json(required(command)) })
75
+ if (request.method !== "ui.press") throw new Error("invalid ui.press params")
76
+ return (await ui()).pressKey(request.params.key, request.params.modifiers)
93
77
  }
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"))
78
+ case "ui.enter": return (await ui()).pressEnter()
79
+ case "ui.arrow": {
80
+ const request = Frontend.decodeRequest({ jsonrpc: "2.0", method: "ui.arrow", params: json(required(command)) })
81
+ if (request.method !== "ui.arrow") throw new Error("invalid ui.arrow params")
82
+ return (await ui()).pressArrow(request.params.direction)
98
83
  }
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 }
84
+ case "ui.focus": {
85
+ const request = Frontend.decodeRequest({ jsonrpc: "2.0", method: "ui.focus", params: json(required(command)) })
86
+ if (request.method !== "ui.focus") throw new Error("invalid ui.focus params")
87
+ return (await ui()).focus(request.params.target)
88
+ }
89
+ case "ui.click": {
90
+ const request = Frontend.decodeRequest({ jsonrpc: "2.0", method: "ui.click", params: json(required(command)) })
91
+ if (request.method !== "ui.click") throw new Error("invalid ui.click params")
92
+ return (await ui()).click(request.params.target, request.params.x, request.params.y)
106
93
  }
94
+ case "ui.screenshot": return (await ui()).screenshot()
95
+ case "ui.state": return (await ui()).state()
96
+ case "ui.start-record": return (await ui()).startRecord()
97
+ case "ui.end-record": return (await ui()).endRecord()
98
+ case "llm.pending": return (await backend()).pendingExchanges()
107
99
  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>)
100
+ const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.chunk", params: json(required(command)) })
101
+ if (request.method !== "llm.chunk") throw new Error("invalid llm.chunk params")
102
+ return (await backend()).chunk(request.params.id, request.params.items)
111
103
  }
112
104
  case "llm.finish": {
113
- const input = scalarOrObject(required(command), "id")
114
- return (await backend()).finish(string(input, "id"), finishReason(input.reason))
105
+ const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.finish", params: json(required(command)) })
106
+ if (request.method !== "llm.finish") throw new Error("invalid llm.finish params")
107
+ return (await backend()).finish(request.params.id, request.params.reason)
108
+ }
109
+ case "llm.disconnect": {
110
+ const request = Backend.decodeRequest({ jsonrpc: "2.0", method: "llm.disconnect", params: json(required(command)) })
111
+ if (request.method !== "llm.disconnect") throw new Error("invalid llm.disconnect params")
112
+ return (await backend()).disconnect(request.params.id)
115
113
  }
116
- case "llm.disconnect": return (await backend()).disconnect(required(command))
117
- case "network.log": return (await backend()).networkLog()
118
114
  }
119
115
  throw new Error(`unknown drive command "${command.operation}"`)
120
116
  }
@@ -124,52 +120,6 @@ function required(command: DriveCommand) {
124
120
  return command.value
125
121
  }
126
122
 
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
123
+ function json(value: string): unknown {
124
+ return JSON.parse(value)
175
125
  }
package/src/cli/index.ts CHANGED
@@ -7,6 +7,8 @@ import { describe } from "./describe.js"
7
7
  import { extractCommands } from "./parse.js"
8
8
  import { start } from "./start.js"
9
9
  import { stop } from "./stop.js"
10
+ import { api } from "./api.js"
11
+ import { restart } from "./restart.js"
10
12
  import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
11
13
 
12
14
  const extracted = extract()
@@ -48,7 +50,7 @@ const sendCommand = Command.make("send", { name, driver }, (config) =>
48
50
  Command.withDescription("Connect to an existing drive-enabled OpenCode instance"),
49
51
  Command.withExamples([
50
52
  {
51
- command: "opencode-drive send --name demo --command.type hello --command.press enter --command.render --command.state",
53
+ command: "opencode-drive send --name demo --command.ui.type '{\"text\":\"hello\"}' --command.ui.state",
52
54
  description: "Execute an ordered command batch",
53
55
  },
54
56
  ]),
@@ -64,9 +66,18 @@ const stopCommand = Command.make("stop", { name }, (config) =>
64
66
  Command.withDescription("Stop a registered headless OpenCode instance"),
65
67
  )
66
68
 
69
+ const restartCommand = Command.make("restart", { name }, (config) =>
70
+ execute(() => restart(Option.getOrUndefined(config.name)))).pipe(
71
+ Command.withDescription("Restart a registered OpenCode client"),
72
+ )
73
+
74
+ const apiCommand = Command.make("api", {}, () => execute(api)).pipe(
75
+ Command.withDescription("Print the OpenCode drive protocol"),
76
+ )
77
+
67
78
  const root = Command.make("opencode-drive").pipe(
68
79
  Command.withDescription("Drive real and simulated OpenCode instances"),
69
- Command.withSubcommands([startCommand, sendCommand, describeCommand, stopCommand]),
80
+ Command.withSubcommands([startCommand, sendCommand, describeCommand, stopCommand, restartCommand, apiCommand]),
70
81
  )
71
82
 
72
83
  Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
@@ -1,4 +1,4 @@
1
- import { mkdir, rm } from "node:fs/promises"
1
+ import { chmod, mkdir, rm } from "node:fs/promises"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
4
  import { registerInstance, registryDirectory, transferInstance, unregisterInstance } from "./registry.js"
@@ -89,6 +89,7 @@ export async function launchInstance(options: LaunchOptions = {}) {
89
89
  OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
90
90
  OPENCODE_CONFIG_DIR: join(cwd, ".opencode"),
91
91
  OPENCODE_DB: ":memory:",
92
+ OPENCODE_TEST_HOME: artifacts,
92
93
  XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
93
94
  XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
94
95
  XDG_DATA_HOME: join(artifacts, "home", ".local", "share"),
@@ -98,19 +99,22 @@ export async function launchInstance(options: LaunchOptions = {}) {
98
99
  ? await prepareDev(cwd, options.dev)
99
100
  : options.command?.length
100
101
  ? [...options.command]
101
- : ["opencode2", "--standalone"]
102
+ : ["opencode2"]
103
+ const launch = join(artifacts, "launch.json")
104
+ await Bun.write(launch, `${JSON.stringify({ command, environment }, undefined, 2)}\n`)
105
+ await chmod(launch, 0o600)
102
106
  const visible = options.visible === true
103
- const child = Bun.spawn(command, {
104
- cwd,
105
- env: environment,
106
- stdin: visible ? "inherit" : "ignore",
107
- stdout: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stdout.log")),
108
- stderr: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stderr.log")),
109
- })
110
- const lifecycle = { detached: false, stopping: undefined as Promise<void> | undefined }
107
+ let child = spawn(command, environment, manifest)
108
+ const lifecycle = {
109
+ detached: false,
110
+ stopping: undefined as Promise<void> | undefined,
111
+ restarting: undefined as Promise<void> | undefined,
112
+ }
111
113
  return {
112
114
  manifest,
113
- child,
115
+ get child() {
116
+ return child
117
+ },
114
118
  async waitForDrive(requirement: "ui" | "backend" | "both" = "both", timeout = 60_000) {
115
119
  const urls = requirement === "both"
116
120
  ? [endpoints.ui, endpoints.backend]
@@ -122,10 +126,37 @@ export async function launchInstance(options: LaunchOptions = {}) {
122
126
  child.unref()
123
127
  lifecycle.detached = true
124
128
  },
129
+ restart() {
130
+ if (lifecycle.restarting) return lifecycle.restarting
131
+ lifecycle.restarting = (async () => {
132
+ await terminate(child)
133
+ child = spawn(command, environment, manifest)
134
+ await Promise.all([
135
+ waitForWebSocket(endpoints.ui, child.exited, 60_000),
136
+ waitForWebSocket(endpoints.backend, child.exited, 60_000),
137
+ ])
138
+ })().finally(() => {
139
+ lifecycle.restarting = undefined
140
+ })
141
+ return lifecycle.restarting
142
+ },
143
+ async wait() {
144
+ while (true) {
145
+ const current = child
146
+ const status = await current.exited
147
+ if (lifecycle.restarting) {
148
+ await lifecycle.restarting
149
+ continue
150
+ }
151
+ if (child !== current) continue
152
+ return status
153
+ }
154
+ },
125
155
  stop(force = false) {
126
156
  if (lifecycle.detached) return Promise.resolve()
127
157
  if (lifecycle.stopping) return lifecycle.stopping
128
158
  lifecycle.stopping = (async () => {
159
+ if (lifecycle.restarting) await lifecycle.restarting.catch(() => undefined)
129
160
  if (force) {
130
161
  if (child.exitCode === null) child.kill("SIGKILL")
131
162
  } else if (child.exitCode === null) {
@@ -141,6 +172,53 @@ export async function launchInstance(options: LaunchOptions = {}) {
141
172
  }
142
173
  }
143
174
 
175
+ async function terminate(child: Bun.Subprocess) {
176
+ if (child.exitCode !== null) return
177
+ child.kill("SIGTERM")
178
+ await Promise.race([child.exited, Bun.sleep(1_000)])
179
+ if (child.exitCode === null) child.kill("SIGKILL")
180
+ await child.exited
181
+ }
182
+
183
+ export async function restartInstance(manifest: InstanceManifest) {
184
+ const value: unknown = await Bun.file(join(manifest.artifacts, "launch.json")).json()
185
+ if (!isLaunchInfo(value)) throw new Error(`drive instance "${manifest.name}" has no valid restart metadata`)
186
+ const child = spawn(value.command, value.environment, manifest)
187
+ await transferInstance(manifest, child.pid)
188
+ try {
189
+ await Promise.all([
190
+ waitForWebSocket(manifest.endpoints.ui, child.exited, 60_000),
191
+ waitForWebSocket(manifest.endpoints.backend, child.exited, 60_000),
192
+ ])
193
+ child.unref()
194
+ return child.pid
195
+ } catch (error) {
196
+ if (child.exitCode === null) child.kill("SIGKILL")
197
+ await child.exited
198
+ throw error
199
+ }
200
+ }
201
+
202
+ function spawn(command: ReadonlyArray<string>, environment: Readonly<Record<string, string>>, manifest: InstanceManifest) {
203
+ return Bun.spawn([...command], {
204
+ cwd: manifest.cwd,
205
+ env: environment,
206
+ stdin: manifest.headless ? "ignore" : "inherit",
207
+ stdout: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stdout.log")) : "inherit",
208
+ stderr: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stderr.log")) : "inherit",
209
+ })
210
+ }
211
+
212
+ function isLaunchInfo(value: unknown): value is {
213
+ readonly command: ReadonlyArray<string>
214
+ readonly environment: Readonly<Record<string, string>>
215
+ } {
216
+ if (typeof value !== "object" || value === null) return false
217
+ if (!("command" in value) || !Array.isArray(value.command) || !value.command.every((item) => typeof item === "string")) return false
218
+ if (!("environment" in value) || typeof value.environment !== "object" || value.environment === null || Array.isArray(value.environment)) return false
219
+ return Object.values(value.environment).every((item) => typeof item === "string")
220
+ }
221
+
144
222
  async function prepareDev(cwd: string, directory: string) {
145
223
  const root = resolve(directory)
146
224
  const entrypoint = join(root, "packages", "cli", "src", "index.ts")
@@ -168,7 +246,6 @@ async function prepareDev(cwd: string, directory: string) {
168
246
  "--conditions=browser",
169
247
  "--preload=@opentui/solid/preload",
170
248
  entrypoint,
171
- "--standalone",
172
249
  ]
173
250
  }
174
251
 
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { join } from "node:path"
3
+ import { restartInstance } from "./instance.js"
4
+ import { resolveInstance } from "./registry.js"
5
+
6
+ export async function restart(name?: string) {
7
+ const manifest = await resolveInstance(name ?? "default")
8
+ if (!manifest.headless) {
9
+ await restartVisible(manifest)
10
+ console.log("success")
11
+ return
12
+ }
13
+ process.kill(manifest.pid, "SIGTERM")
14
+ await Promise.race([waitForExit(manifest.pid), Bun.sleep(1_000)])
15
+ if (alive(manifest.pid)) process.kill(manifest.pid, "SIGKILL")
16
+ await waitForExit(manifest.pid)
17
+ await restartInstance(manifest)
18
+ console.log("success")
19
+ }
20
+
21
+ async function restartVisible(manifest: Awaited<ReturnType<typeof resolveInstance>>) {
22
+ if (!(await Bun.file(join(manifest.artifacts, "launch.json")).exists())) {
23
+ throw new Error(`drive instance "${manifest.name}" was started by a version that does not support restart`)
24
+ }
25
+ const token = randomUUID()
26
+ const request = join(manifest.artifacts, "restart-request.json")
27
+ const response = join(manifest.artifacts, "restart-response.json")
28
+ await Bun.file(response).delete().catch(() => undefined)
29
+ await Bun.write(request, `${JSON.stringify({ token })}\n`)
30
+ const deadline = Date.now() + 60_000
31
+ while (Date.now() < deadline) {
32
+ const value = await Bun.file(response).json().catch(() => undefined)
33
+ if (isRestartResponse(value) && value.token === token) {
34
+ if (!value.success) throw new Error(value.error ?? "visible restart failed")
35
+ return
36
+ }
37
+ if (!alive(manifest.pid)) throw new Error(`drive instance "${manifest.name}" exited while restarting`)
38
+ await Bun.sleep(25)
39
+ }
40
+ throw new Error(`timed out restarting drive instance "${manifest.name}"`)
41
+ }
42
+
43
+ function isRestartResponse(value: unknown): value is {
44
+ readonly token: string
45
+ readonly success: boolean
46
+ readonly error?: string
47
+ } {
48
+ if (typeof value !== "object" || value === null) return false
49
+ if (!("token" in value) || typeof value.token !== "string") return false
50
+ return "success" in value && typeof value.success === "boolean"
51
+ }
52
+
53
+ function waitForExit(pid: number) {
54
+ return new Promise<void>((resolve) => {
55
+ const check = () => {
56
+ if (!alive(pid)) return resolve()
57
+ setTimeout(check, 25)
58
+ }
59
+ check()
60
+ })
61
+ }
62
+
63
+ function alive(pid: number) {
64
+ try {
65
+ process.kill(pid, 0)
66
+ return true
67
+ } catch {
68
+ return false
69
+ }
70
+ }
package/src/cli/send.ts CHANGED
@@ -12,11 +12,11 @@ export async function send(options: SendOptions) {
12
12
  : await resolveInstance("default").catch(() => defaultManifest())
13
13
  if (options.commands.length > 0) {
14
14
  const result = await executeCommands(manifest, options.commands)
15
- if (options.commands.length === 1 && ["render", "end-record"].includes(options.commands[0]?.operation ?? "")) {
15
+ if (options.commands.length === 1 && ["ui.screenshot", "ui.end-record"].includes(options.commands[0]?.operation ?? "")) {
16
16
  console.log(result.results[0]?.result)
17
17
  return
18
18
  }
19
- if (options.commands.length === 1 && ["llm.pending", "state", "start-record"].includes(options.commands[0]?.operation ?? "")) {
19
+ if (options.commands.length === 1 && ["llm.pending", "ui.state", "ui.start-record"].includes(options.commands[0]?.operation ?? "")) {
20
20
  console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
21
21
  return
22
22
  }
package/src/cli/start.ts CHANGED
@@ -4,6 +4,7 @@ import { runCampaign } from "../experimental/cli-campaign.js"
4
4
  import { runDriver } from "./driver.js"
5
5
  import { launchInstance } from "./instance.js"
6
6
  import type { StartOptions } from "./types.js"
7
+ import { join } from "node:path"
7
8
 
8
9
  export async function start(options: StartOptions) {
9
10
  if (options.campaign) return runCampaign(options)
@@ -23,6 +24,7 @@ export async function start(options: StartOptions) {
23
24
  return
24
25
  }
25
26
  const interrupt = () => void instance.stop()
27
+ const stopRestart = options.visible ? watchVisibleRestarts(instance) : () => {}
26
28
  process.once("SIGINT", interrupt)
27
29
  process.once("SIGTERM", interrupt)
28
30
  try {
@@ -30,11 +32,11 @@ export async function start(options: StartOptions) {
30
32
  await instance.waitForDrive("both")
31
33
  const result = await executeCommands(instance.manifest, options.commands)
32
34
  await instance.stop()
33
- if (options.commands.length === 1 && ["render", "end-record"].includes(options.commands[0]?.operation ?? "")) {
35
+ if (options.commands.length === 1 && ["ui.screenshot", "ui.end-record"].includes(options.commands[0]?.operation ?? "")) {
34
36
  console.log(result.results[0]?.result)
35
37
  return
36
38
  }
37
- if (options.commands.length === 1 && ["llm.pending", "state", "start-record"].includes(options.commands[0]?.operation ?? "")) {
39
+ if (options.commands.length === 1 && ["llm.pending", "ui.state", "ui.start-record"].includes(options.commands[0]?.operation ?? "")) {
38
40
  console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
39
41
  return
40
42
  }
@@ -46,11 +48,51 @@ export async function start(options: StartOptions) {
46
48
  await runDriver(resolve(options.driver), instance.manifest)
47
49
  return
48
50
  }
49
- const status = await instance.child.exited
51
+ const status = await instance.wait()
50
52
  if (status !== 0) process.exitCode = status
51
53
  } finally {
52
54
  process.off("SIGINT", interrupt)
53
55
  process.off("SIGTERM", interrupt)
56
+ stopRestart()
54
57
  await instance.stop()
55
58
  }
56
59
  }
60
+
61
+ function watchVisibleRestarts(instance: Awaited<ReturnType<typeof launchInstance>>) {
62
+ const request = join(instance.manifest.artifacts, "restart-request.json")
63
+ const response = join(instance.manifest.artifacts, "restart-response.json")
64
+ const state = { token: undefined as string | undefined, busy: false }
65
+ const timer = setInterval(() => {
66
+ if (state.busy) return
67
+ state.busy = true
68
+ void handleVisibleRestart(instance, request, response, state).finally(() => {
69
+ state.busy = false
70
+ })
71
+ }, 50)
72
+ return () => clearInterval(timer)
73
+ }
74
+
75
+ async function handleVisibleRestart(
76
+ instance: Awaited<ReturnType<typeof launchInstance>>,
77
+ request: string,
78
+ response: string,
79
+ state: { token: string | undefined },
80
+ ) {
81
+ const value: unknown = await Bun.file(request).json().catch(() => undefined)
82
+ if (!isRestartRequest(value) || value.token === state.token) return
83
+ state.token = value.token
84
+ try {
85
+ await instance.restart()
86
+ await Bun.write(response, `${JSON.stringify({ token: value.token, success: true })}\n`)
87
+ } catch (error) {
88
+ await Bun.write(response, `${JSON.stringify({
89
+ token: value.token,
90
+ success: false,
91
+ error: error instanceof Error ? error.message : String(error),
92
+ })}\n`)
93
+ }
94
+ }
95
+
96
+ function isRestartRequest(value: unknown): value is { readonly token: string } {
97
+ return typeof value === "object" && value !== null && "token" in value && typeof value.token === "string"
98
+ }
@@ -11,7 +11,6 @@ type BackendMethods = {
11
11
  readonly "llm.finish": { readonly params: Partial<Backend.FinishParams> & Pick<Backend.FinishParams, "id">; readonly result: { readonly ok: true } }
12
12
  readonly "llm.disconnect": { readonly params: Backend.DisconnectParams; readonly result: { readonly ok: true } }
13
13
  readonly "llm.pending": { readonly params: undefined; readonly result: { readonly exchanges: ReadonlyArray<Backend.OpenedExchange> } }
14
- readonly "network.log": { readonly params: undefined; readonly result: { readonly entries: ReadonlyArray<Backend.NetworkLogEntry> } }
15
14
  }
16
15
 
17
16
  type BackendMethodName = keyof BackendMethods
@@ -110,10 +109,6 @@ export class BackendSimulationClient {
110
109
  return this.call("llm.pending")
111
110
  }
112
111
 
113
- networkLog(): Promise<{ readonly entries: ReadonlyArray<Backend.NetworkLogEntry> }> {
114
- return this.call("network.log")
115
- }
116
-
117
112
  close() {
118
113
  this.socket.close()
119
114
  }
@@ -6,14 +6,16 @@ import {
6
6
  const defaultPort = 40900
7
7
 
8
8
  type Methods = {
9
- readonly "ui.render": { readonly params: undefined; readonly result: Frontend.Render }
9
+ readonly "ui.screenshot": { readonly params: undefined; readonly result: Frontend.Screenshot }
10
10
  readonly "ui.state": { readonly params: undefined; readonly result: Frontend.State }
11
11
  readonly "ui.start-record": { readonly params: undefined; readonly result: Frontend.StartRecord }
12
12
  readonly "ui.end-record": { readonly params: undefined; readonly result: Frontend.EndRecord }
13
- readonly "ui.action": { readonly params: Frontend.ActionParams; readonly result: Frontend.State }
14
- readonly "trace.list": { readonly params: undefined; readonly result: Frontend.TraceList }
15
- readonly "trace.clear": { readonly params: undefined; readonly result: { readonly cleared: true } }
16
- readonly "trace.export": { readonly params: undefined; readonly result: Frontend.TraceList }
13
+ readonly "ui.type": { readonly params: Frontend.TypeParams; readonly result: Frontend.State }
14
+ readonly "ui.press": { readonly params: Frontend.PressParams; readonly result: Frontend.State }
15
+ readonly "ui.enter": { readonly params: undefined; readonly result: Frontend.State }
16
+ readonly "ui.arrow": { readonly params: Frontend.ArrowParams; readonly result: Frontend.State }
17
+ readonly "ui.focus": { readonly params: Frontend.FocusParams; readonly result: Frontend.State }
18
+ readonly "ui.click": { readonly params: Frontend.ClickParams; readonly result: Frontend.State }
17
19
  }
18
20
 
19
21
  type MethodName = keyof Methods
@@ -123,8 +125,8 @@ export class SimulationClient {
123
125
  return this.call("ui.state")
124
126
  }
125
127
 
126
- render(): Promise<Frontend.Render> {
127
- return this.call("ui.render")
128
+ screenshot(): Promise<Frontend.Screenshot> {
129
+ return this.call("ui.screenshot")
128
130
  }
129
131
 
130
132
  startRecord(): Promise<Frontend.StartRecord> {
@@ -136,46 +138,28 @@ export class SimulationClient {
136
138
  }
137
139
 
138
140
  /** Executes one user-level action and returns the post-action state. */
139
- action(action: Frontend.Action): Promise<Frontend.State> {
140
- return this.call("ui.action", { action })
141
- }
142
-
143
141
  typeText(text: string): Promise<Frontend.State> {
144
- return this.action({ type: "typeText", text })
142
+ return this.call("ui.type", { text })
145
143
  }
146
144
 
147
145
  pressKey(key: string, modifiers?: Frontend.KeyModifiers): Promise<Frontend.State> {
148
- return this.action({ type: "pressKey", key: key === "escape" ? "\u001b" : key, ...(modifiers === undefined ? {} : { modifiers }) })
146
+ return this.call("ui.press", { key: key === "escape" ? "\u001b" : key, ...(modifiers === undefined ? {} : { modifiers }) })
149
147
  }
150
148
 
151
149
  pressEnter(): Promise<Frontend.State> {
152
- return this.action({ type: "pressEnter" })
150
+ return this.call("ui.enter")
153
151
  }
154
152
 
155
153
  pressArrow(direction: "up" | "down" | "left" | "right"): Promise<Frontend.State> {
156
- return this.action({ type: "pressArrow", direction })
154
+ return this.call("ui.arrow", { direction })
157
155
  }
158
156
 
159
157
  focus(target: number): Promise<Frontend.State> {
160
- return this.action({ type: "focus", target })
158
+ return this.call("ui.focus", { target })
161
159
  }
162
160
 
163
161
  click(target: number, x: number, y: number): Promise<Frontend.State> {
164
- return this.action({ type: "click", target, x, y })
165
- }
166
-
167
- // ── trace ─────────────────────────────────────────────────────────────
168
-
169
- traceList(): Promise<Frontend.TraceList> {
170
- return this.call("trace.list")
171
- }
172
-
173
- traceClear(): Promise<{ readonly cleared: true }> {
174
- return this.call("trace.clear")
175
- }
176
-
177
- traceExport(): Promise<Frontend.TraceList> {
178
- return this.call("trace.export")
162
+ return this.call("ui.click", { target, x, y })
179
163
  }
180
164
 
181
165
  // ── lifecycle ─────────────────────────────────────────────────────────
@@ -7,11 +7,8 @@ export const defaultBackendPort = 40950
7
7
  export { Backend, Frontend, JsonRpc, SimulationProtocol } from "./protocol.js"
8
8
  export type BackendFinishReason = import("./protocol.js").Backend.FinishReason
9
9
  export type BackendItem = import("./protocol.js").Backend.Item
10
- export type NetworkLogEntry = import("./protocol.js").Backend.NetworkLogEntry
11
10
  export type OpenedExchange = import("./protocol.js").Backend.OpenedExchange
12
11
  export type KeyModifiers = import("./protocol.js").Frontend.KeyModifiers
13
- export type TraceList = import("./protocol.js").Frontend.TraceList
14
- export type TraceRecord = import("./protocol.js").Frontend.TraceRecord
15
12
  export type UiAction = import("./protocol.js").Frontend.Action
16
13
  export type UiElement = import("./protocol.js").Frontend.Element
17
14
  export type UiState = import("./protocol.js").Frontend.State
@@ -59,12 +59,12 @@ export namespace Frontend {
59
59
  export interface KeyModifiers extends Schema.Schema.Type<typeof KeyModifiers> {}
60
60
 
61
61
  export const Action = Schema.Union([
62
- Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }),
63
- Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
64
- Schema.Struct({ type: Schema.Literal("pressEnter") }),
65
- Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
66
- Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }),
67
- Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
62
+ Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }),
63
+ Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }),
64
+ Schema.Struct({ type: Schema.Literal("ui.enter") }),
65
+ Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
66
+ Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
67
+ Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
68
68
  ])
69
69
  export type Action = Schema.Schema.Type<typeof Action>
70
70
 
@@ -88,12 +88,11 @@ export namespace Frontend {
88
88
  editor: Schema.Boolean,
89
89
  }),
90
90
  elements: Schema.Array(Element),
91
- actions: Schema.Array(Action),
92
91
  })
93
92
  export interface State extends Schema.Schema.Type<typeof State> {}
94
93
 
95
- export const Render = Schema.String
96
- export type Render = Schema.Schema.Type<typeof Render>
94
+ export const Screenshot = Schema.String
95
+ export type Screenshot = Schema.Schema.Type<typeof Screenshot>
97
96
 
98
97
  export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) })
99
98
  export interface StartRecord extends Schema.Schema.Type<typeof StartRecord> {}
@@ -101,37 +100,41 @@ export namespace Frontend {
101
100
  export const EndRecord = Schema.String
102
101
  export type EndRecord = Schema.Schema.Type<typeof EndRecord>
103
102
 
104
- export const ActionParams = Schema.Struct({ action: Action })
105
- export interface ActionParams extends Schema.Schema.Type<typeof ActionParams> {}
103
+ export const TypeParams = Schema.Struct({ text: Schema.String })
104
+ export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
105
+
106
+ export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) })
107
+ export interface PressParams extends Schema.Schema.Type<typeof PressParams> {}
108
+
109
+ export const ArrowParams = Schema.Struct({ direction: Schema.Literals(["up", "down", "left", "right"]) })
110
+ export interface ArrowParams extends Schema.Schema.Type<typeof ArrowParams> {}
111
+
112
+ export const FocusParams = Schema.Struct({ target: Schema.Number })
113
+ export interface FocusParams extends Schema.Schema.Type<typeof FocusParams> {}
114
+
115
+ export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
116
+ export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
106
117
 
107
118
  export const Request = Schema.Union([
108
- Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }),
119
+ Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
120
+ Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }),
121
+ Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
122
+ Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }),
123
+ Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
109
124
  Schema.Struct({
110
125
  ...JsonRpc.RequestFields,
111
126
  method: Schema.Literals([
112
- "ui.render",
127
+ "ui.enter",
128
+ "ui.screenshot",
113
129
  "ui.state",
114
130
  "ui.start-record",
115
131
  "ui.end-record",
116
- "trace.list",
117
- "trace.clear",
118
- "trace.export",
119
132
  ]),
120
133
  }),
121
134
  ])
122
135
  export type Request = Schema.Schema.Type<typeof Request>
123
136
  export const decodeRequest = Schema.decodeUnknownSync(Request)
124
137
 
125
- export const TraceRecord = Schema.Struct({
126
- id: Schema.Number,
127
- time: Schema.String,
128
- type: Schema.String,
129
- data: Schema.optional(Schema.Json),
130
- })
131
- export interface TraceRecord extends Schema.Schema.Type<typeof TraceRecord> {}
132
-
133
- export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) })
134
- export interface TraceList extends Schema.Schema.Type<typeof TraceList> {}
135
138
  }
136
139
 
137
140
  export namespace Backend {
@@ -164,7 +167,7 @@ export namespace Backend {
164
167
  Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
165
168
  Schema.Struct({
166
169
  ...JsonRpc.RequestFields,
167
- method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]),
170
+ method: Schema.Literals(["llm.attach", "llm.pending"]),
168
171
  }),
169
172
  ])
170
173
  export type Request = Schema.Schema.Type<typeof Request>
@@ -0,0 +1,68 @@
1
+ // CLI command contract for `opencode-drive send`.
2
+
3
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
4
+
5
+ export type KeyModifiers = {
6
+ ctrl?: boolean
7
+ shift?: boolean
8
+ meta?: boolean
9
+ super?: boolean
10
+ hyper?: boolean
11
+ }
12
+
13
+ export type Element = {
14
+ id: string
15
+ num: number
16
+ x: number
17
+ y: number
18
+ width: number
19
+ height: number
20
+ focusable: boolean
21
+ focused: boolean
22
+ clickable: boolean
23
+ editor: boolean
24
+ }
25
+
26
+ export type State = {
27
+ focused: {
28
+ renderable?: number
29
+ editor: boolean
30
+ }
31
+ elements: Element[]
32
+ }
33
+
34
+ export type LlmItem =
35
+ | { type: "textDelta"; text: string }
36
+ | { type: "reasoningDelta"; text: string }
37
+ | { type: "toolCall"; id: string; name: string; input: Json }
38
+ | { type: "raw"; chunk: Json }
39
+
40
+ export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter"
41
+
42
+ export type OpenedExchange = {
43
+ id: string
44
+ url: string
45
+ body: Json
46
+ }
47
+
48
+ export type Command =
49
+ | { name: "ui.type"; params: { text: string }; result: State }
50
+ | { name: "ui.press"; params: { key: string; modifiers?: KeyModifiers }; result: State }
51
+ | { name: "ui.enter"; result: State }
52
+ | { name: "ui.arrow"; params: { direction: "up" | "down" | "left" | "right" }; result: State }
53
+ | { name: "ui.focus"; params: { target: number }; result: State }
54
+ | { name: "ui.click"; params: { target: number; x: number; y: number }; result: State }
55
+ | { name: "ui.screenshot"; result: string }
56
+ | { name: "ui.state"; result: State }
57
+ | { name: "ui.start-record"; result: { recording: true } }
58
+ | { name: "ui.end-record"; result: string }
59
+ | { name: "llm.chunk"; params: { id: string; items: LlmItem[] }; result: { ok: true } }
60
+ | { name: "llm.finish"; params: { id: string; reason?: FinishReason }; result: { ok: true } }
61
+ | { name: "llm.disconnect"; params: { id: string }; result: { ok: true } }
62
+ | { name: "llm.attach"; result: { attached: true } }
63
+ | { name: "llm.pending"; result: { exchanges: OpenedExchange[] } }
64
+
65
+ // Commands with `params` take one JSON argument:
66
+ // --command.ui.type '{"text":"hello"}'
67
+ // Commands without `params` are flags:
68
+ // --command.ui.enter
@@ -83,7 +83,7 @@ for (let index = 0; index < options.count; index++) {
83
83
  "--driver", `bun ${path.resolve("src/experimental/flow-driver.ts")} ${path.join(directory, "scenario.json")} ${path.join(directory, "result.json")}`,
84
84
  "--",
85
85
  "bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
86
- "/root/projects/opencode-latest/packages/cli/src/index.ts", "--standalone",
86
+ "/root/projects/opencode-latest/packages/cli/src/index.ts",
87
87
  ], {
88
88
  cwd: path.resolve("."),
89
89
  env: {
@@ -107,7 +107,7 @@ for (let index = 0; index < options.count; index++) {
107
107
  }
108
108
  const result: FlowResult = await Bun.file(path.join(directory, "result.json")).json()
109
109
  results.push(result)
110
- console.log(` passed in ${result.durationMs}ms; trace=${result.traceRecords} title=${result.titleExchanges}`)
110
+ console.log(` passed in ${result.durationMs}ms; title=${result.titleExchanges}`)
111
111
  }
112
112
 
113
113
  const summary = {
@@ -117,7 +117,6 @@ const summary = {
117
117
  assistantExchanges: results.reduce((total, result) => total + result.assistantExchanges, 0),
118
118
  subagentExchanges: results.reduce((total, result) => total + result.subagentExchanges, 0),
119
119
  titleExchanges: results.reduce((total, result) => total + result.titleExchanges, 0),
120
- traceRecords: results.reduce((total, result) => total + result.traceRecords, 0),
121
120
  durationMs: results.reduce((total, result) => total + result.durationMs, 0),
122
121
  coverage: { ...coverage, streamChunkTypes: [...coverage.streamChunkTypes] },
123
122
  }
@@ -100,7 +100,6 @@ try {
100
100
  if (stepDelay > 0) await Bun.sleep(stepDelay)
101
101
  }
102
102
  await waitFor("provider idle", async () => (await backend.pendingExchanges()).exchanges.length === 0)
103
- const trace = await ui.traceExport()
104
103
  const result: FlowResult = {
105
104
  seed: scenario.seed,
106
105
  name: scenario.name,
@@ -108,7 +107,6 @@ try {
108
107
  assistantExchanges,
109
108
  subagentExchanges,
110
109
  titleExchanges,
111
- traceRecords: trace.records.length,
112
110
  durationMs: Date.now() - started,
113
111
  finalState: await ui.state(),
114
112
  }
@@ -41,7 +41,6 @@ export interface FlowResult {
41
41
  readonly assistantExchanges: number
42
42
  readonly subagentExchanges: number
43
43
  readonly titleExchanges: number
44
- readonly traceRecords: number
45
44
  readonly durationMs: number
46
45
  readonly finalState: UiState
47
46
  }
@@ -38,7 +38,7 @@ const child = Bun.spawn([
38
38
  "--driver", `bun ${path.resolve("src/experimental/stale-running-driver.ts")}`,
39
39
  "--",
40
40
  "bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
41
- "/root/projects/opencode-latest/packages/cli/src/index.ts", "--standalone",
41
+ "/root/projects/opencode-latest/packages/cli/src/index.ts",
42
42
  ], {
43
43
  cwd: path.resolve("."),
44
44
  env: {