opencode-drive 0.1.7 → 0.1.8

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
@@ -22,7 +22,9 @@ If you installed the skill file, OpenCode will be able to see and interact with
22
22
 
23
23
  ## Using with agents
24
24
 
25
- Install the skill file above and ask the agent to test various flows with the app. You also ask it to make a recording and it will generate a video file for you to watch anything it did.
25
+ Install the skill file above and ask the agent to test various flows with the app. Start with `--record` when you want a video; `opencode-drive stop` then exports the complete session and prints its path.
26
+
27
+ Screenshots and videos are written to `<system temp>/opencode-drive/output` with unique filenames. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different directory.
26
28
 
27
29
  ## UI development
28
30
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-drive",
4
- "version": "0.1.7",
4
+ "version": "0.1.8",
5
5
  "description": "Drive real and simulated OpenCode instances",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -12,26 +12,32 @@
12
12
  "exports": {
13
13
  ".": "./src/index.ts",
14
14
  "./client": "./src/client/index.ts",
15
- "./experimental": "./src/experimental/index.ts"
15
+ "./experimental": "./src/experimental/index.ts",
16
+ "./recording": "./src/recording/index.ts"
16
17
  },
17
18
  "files": [
18
19
  "bin/opencode-drive",
19
20
  "src/cli",
20
21
  "src/client",
21
22
  "src/experimental",
23
+ "src/recording",
22
24
  "src/index.ts",
23
25
  "README.md"
24
26
  ],
25
27
  "scripts": {
26
28
  "drive": "bun run src/cli/index.ts",
27
29
  "lint": "oxlint src",
28
- "test": "bun test test/cli/parse.test.ts test/cli/integration.test.ts",
30
+ "test": "bun test test",
29
31
  "typecheck": "tsgo --noEmit",
30
32
  "check": "bun run lint && bun run typecheck"
31
33
  },
32
34
  "dependencies": {
33
35
  "@effect/platform-node": "4.0.0-beta.93",
34
36
  "@effect/platform-node-shared": "4.0.0-beta.93",
37
+ "@fontsource/adwaita-mono": "5.2.1",
38
+ "@napi-rs/canvas": "1.0.2",
39
+ "@wterm/core": "0.3.0",
40
+ "@wterm/ghostty": "0.3.0",
35
41
  "effect": "4.0.0-beta.93"
36
42
  },
37
43
  "devDependencies": {
@@ -22,10 +22,9 @@ export const commandInfo = {
22
22
  value: false,
23
23
  description: "Return focus, elements, and available UI actions",
24
24
  },
25
- "ui.start-record": { value: false, description: "Start recording the UI" },
26
- "ui.end-record": {
25
+ "ui.recording.finish": {
27
26
  value: false,
28
- description: "Stop recording and return the recording path",
27
+ description: "Finish recording and return the timeline path",
29
28
  },
30
29
  } as const
31
30
 
@@ -38,8 +37,7 @@ export function commandAcceptsValue(operation: string) {
38
37
  if (operation === "ui.click") return commandInfo[operation].value
39
38
  if (operation === "ui.screenshot") return commandInfo[operation].value
40
39
  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
40
+ if (operation === "ui.recording.finish") return commandInfo[operation].value
43
41
  throw new Error(`unknown drive command "${operation}"`)
44
42
  }
45
43
 
@@ -142,10 +140,8 @@ async function execute(
142
140
  return ui.screenshot()
143
141
  case "ui.state":
144
142
  return ui.state()
145
- case "ui.start-record":
146
- return ui.startRecord()
147
- case "ui.end-record":
148
- return ui.endRecord()
143
+ case "ui.recording.finish":
144
+ return ui.finishRecording()
149
145
  }
150
146
  throw new Error(`unknown drive command "${command.operation}"`)
151
147
  }
@@ -5,11 +5,16 @@ import type {
5
5
  ResponseUpdate,
6
6
  } from "./response-generator.js"
7
7
 
8
+ export interface StopResult {
9
+ readonly recording?: string
10
+ readonly screenshots: ReadonlyArray<string>
11
+ }
12
+
8
13
  export async function listenControl(
9
14
  path: string,
10
15
  handlers: {
11
- readonly restart: () => Promise<void>
12
- readonly stop: () => Promise<void>
16
+ readonly restart: () => Promise<string | undefined>
17
+ readonly stop: (onProgress: (percent: number) => void) => Promise<StopResult>
13
18
  readonly responses: (
14
19
  input: ResponseUpdate,
15
20
  ) => Promise<ResponseConfiguration>
@@ -27,7 +32,8 @@ export async function listenControl(
27
32
  }
28
33
  if (!buffer.includes("\n")) return
29
34
  socket.removeAllListeners("data")
30
- void handle(buffer.slice(0, buffer.indexOf("\n"))).then(
35
+ const progress = (percent: number) => socket.write(`progress ${percent}\n`)
36
+ void handle(buffer.slice(0, buffer.indexOf("\n")), progress).then(
31
37
  (result) =>
32
38
  socket.end(
33
39
  `success${result === undefined ? "" : ` ${JSON.stringify(result)}`}\n`,
@@ -39,9 +45,9 @@ export async function listenControl(
39
45
  )
40
46
  })
41
47
  })
42
- const handle = async (input: string) => {
48
+ const handle = async (input: string, onProgress: (percent: number) => void) => {
43
49
  if (input === "restart") return handlers.restart()
44
- if (input === "stop") return handlers.stop()
50
+ if (input === "stop") return handlers.stop(onProgress)
45
51
  if (input === "responses") return handlers.responses({})
46
52
  if (input.startsWith("responses "))
47
53
  return handlers.responses(parseResponseUpdate(input.slice("responses ".length)))
@@ -54,9 +60,28 @@ export async function listenControl(
54
60
  }
55
61
  }
56
62
 
57
- export async function request(path: string, command: "restart" | "stop") {
63
+ export async function request(
64
+ path: string,
65
+ command: "restart",
66
+ ) {
58
67
  const response = await send(path, command)
59
- if (response !== "success") throw responseError(response)
68
+ if (response === "success") return undefined
69
+ if (!response.startsWith("success ")) throw responseError(response)
70
+ const value: unknown = JSON.parse(response.slice("success ".length))
71
+ if (typeof value !== "string")
72
+ throw new Error("instance returned an invalid recording path")
73
+ return value
74
+ }
75
+
76
+ export async function requestStop(
77
+ path: string,
78
+ onProgress?: (percent: number) => void,
79
+ ) {
80
+ const response = await send(path, "stop", onProgress)
81
+ if (!response.startsWith("success ")) throw responseError(response)
82
+ const value: unknown = JSON.parse(response.slice("success ".length))
83
+ if (!isStopResult(value)) throw new Error("instance returned an invalid stop result")
84
+ return value
60
85
  }
61
86
 
62
87
  export async function requestResponses(path: string, input: ResponseUpdate) {
@@ -73,22 +98,34 @@ export async function requestResponses(path: string, input: ResponseUpdate) {
73
98
  return value
74
99
  }
75
100
 
76
- function send(path: string, command: string) {
101
+ function send(path: string, command: string, onProgress?: (percent: number) => void) {
77
102
  return new Promise<string>((resolve, reject) => {
78
103
  const socket = connect(path)
79
104
  let response = ""
105
+ let buffer = ""
80
106
  const timer = setTimeout(() => {
81
107
  socket.destroy()
82
108
  reject(new Error("instance control request timed out"))
83
- }, 10_000)
109
+ }, 5 * 60_000)
84
110
  socket.setEncoding("utf8")
85
111
  socket.on("connect", () => socket.write(`${command}\n`))
86
112
  socket.on("data", (data) => {
87
- response += data
113
+ buffer += data
114
+ while (buffer.includes("\n")) {
115
+ const index = buffer.indexOf("\n")
116
+ const line = buffer.slice(0, index)
117
+ buffer = buffer.slice(index + 1)
118
+ if (line.startsWith("progress ")) {
119
+ const percent = Number(line.slice("progress ".length))
120
+ if (Number.isInteger(percent)) onProgress?.(percent)
121
+ continue
122
+ }
123
+ response += `${line}\n`
124
+ }
88
125
  })
89
126
  socket.on("end", () => {
90
127
  clearTimeout(timer)
91
- resolve(response.trim())
128
+ resolve(`${response}${buffer}`.trim())
92
129
  })
93
130
  socket.on("error", () => {
94
131
  clearTimeout(timer)
@@ -123,6 +160,13 @@ function isResponseConfiguration(
123
160
  return "tools" in value && stringArrayValue(value.tools)
124
161
  }
125
162
 
163
+ function isStopResult(value: unknown): value is StopResult {
164
+ if (typeof value !== "object" || value === null) return false
165
+ if (!("screenshots" in value) || !stringArrayValue(value.screenshots))
166
+ return false
167
+ return !("recording" in value) || typeof value.recording === "string"
168
+ }
169
+
126
170
  function stringArrayValue(value: unknown) {
127
171
  return Array.isArray(value) && value.every((item) => typeof item === "string")
128
172
  }
package/src/cli/index.ts CHANGED
@@ -38,6 +38,9 @@ const startCommand = Command.make(
38
38
  visible: Flag.boolean("visible").pipe(
39
39
  Flag.withDescription("Show OpenCode in the terminal"),
40
40
  ),
41
+ record: Flag.boolean("record").pipe(
42
+ Flag.withDescription("Record the complete headless session and export it on stop"),
43
+ ),
41
44
  dev: Flag.string("dev").pipe(
42
45
  Flag.optional,
43
46
  Flag.withDescription("Path to an OpenCode development checkout"),
@@ -100,32 +103,6 @@ const screenshotCommand = Command.make("screenshot", { name }, (config) =>
100
103
  ),
101
104
  ).pipe(Command.withDescription("Take a screenshot and print its path"))
102
105
 
103
- const startRecordingCommand = Command.make(
104
- "record-start",
105
- { name },
106
- (config) =>
107
- execute(() =>
108
- send({
109
- kind: "send",
110
- name: Option.getOrUndefined(config.name),
111
- commands: [{ operation: "ui.start-record" }],
112
- }),
113
- ),
114
- ).pipe(Command.withDescription("Start recording the UI"))
115
-
116
- const endRecordingCommand = Command.make(
117
- "record-end",
118
- { name },
119
- (config) =>
120
- execute(() =>
121
- send({
122
- kind: "send",
123
- name: Option.getOrUndefined(config.name),
124
- commands: [{ operation: "ui.end-record" }],
125
- }),
126
- ),
127
- ).pipe(Command.withDescription("Stop recording and print its path"))
128
-
129
106
  const restartCommand = Command.make("restart", { name }, (config) =>
130
107
  execute(() => restart(Option.getOrUndefined(config.name))),
131
108
  ).pipe(
@@ -175,8 +152,6 @@ const root = Command.make("opencode-drive").pipe(
175
152
  startCommand,
176
153
  sendCommand,
177
154
  screenshotCommand,
178
- startRecordingCommand,
179
- endRecordingCommand,
180
155
  listCommand,
181
156
  responsesCommand,
182
157
  logsCommand,
@@ -196,6 +171,7 @@ function toStartOptions(
196
171
  readonly name: string
197
172
  readonly daemon: boolean
198
173
  readonly visible: boolean
174
+ readonly record: boolean
199
175
  readonly dev: Option.Option<string>
200
176
  readonly state: Option.Option<string>
201
177
  },
@@ -210,6 +186,7 @@ function toStartOptions(
210
186
  daemon: config.daemon,
211
187
  script: Option.getOrUndefined(config.script),
212
188
  visible: config.visible,
189
+ record: config.record,
213
190
  dev: Option.getOrUndefined(config.dev),
214
191
  state: Option.getOrUndefined(config.state),
215
192
  command: app,
@@ -1,6 +1,7 @@
1
1
  import { mkdir, rm } from "node:fs/promises"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
+ import { ensureMediaDirectory } from "./media.js"
4
5
 
5
6
  export interface LaunchOptions {
6
7
  readonly name: string
@@ -9,6 +10,7 @@ export interface LaunchOptions {
9
10
  readonly state?: string
10
11
  readonly scripted?: boolean
11
12
  readonly visible?: boolean
13
+ readonly record?: boolean
12
14
  readonly env?: Readonly<Record<string, string>>
13
15
  }
14
16
 
@@ -22,6 +24,7 @@ export async function launchInstance(options: LaunchOptions) {
22
24
  backend: `ws://127.0.0.1:${await freePort()}`,
23
25
  }
24
26
  const drive = join(artifacts, "drive")
27
+ const media = await ensureMediaDirectory()
25
28
  await Promise.all([
26
29
  mkdir(logs, { recursive: true }),
27
30
  mkdir(drive, { recursive: true }),
@@ -30,10 +33,20 @@ export async function launchInstance(options: LaunchOptions) {
30
33
  mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
31
34
  mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
32
35
  ])
33
- await Bun.write(
34
- join(drive, `${options.name}.json`),
35
- `${JSON.stringify({ endpoints }, undefined, 2)}\n`,
36
- )
36
+ let recording = options.record ? recordingPaths(media) : undefined
37
+ const writeDriveManifest = () =>
38
+ Bun.write(
39
+ join(drive, `${options.name}.json`),
40
+ `${JSON.stringify(
41
+ {
42
+ endpoints,
43
+ ...(recording ? { recording: { timeline: recording.timeline } } : {}),
44
+ },
45
+ undefined,
46
+ 2,
47
+ )}\n`,
48
+ )
49
+ await writeDriveManifest()
37
50
  const state = options.state
38
51
  ? resolve(options.state)
39
52
  : join(artifacts, "state")
@@ -124,6 +137,9 @@ export async function launchInstance(options: LaunchOptions) {
124
137
  artifacts,
125
138
  logs,
126
139
  endpoints,
140
+ get recording() {
141
+ return recording
142
+ },
127
143
  get child() {
128
144
  return child
129
145
  },
@@ -143,6 +159,8 @@ export async function launchInstance(options: LaunchOptions) {
143
159
  if (restarting) return restarting
144
160
  restarting = (async () => {
145
161
  await terminate(child)
162
+ recording = options.record ? recordingPaths(media) : undefined
163
+ await writeDriveManifest()
146
164
  child = spawn()
147
165
  await Promise.all([
148
166
  waitForWebSocket(endpoints.ui, child.exited, 60_000),
@@ -177,6 +195,14 @@ export async function launchInstance(options: LaunchOptions) {
177
195
  }
178
196
  }
179
197
 
198
+ function recordingPaths(directory: string) {
199
+ const id = crypto.randomUUID()
200
+ return {
201
+ timeline: join(directory, `recording-${id}.jsonl`),
202
+ video: join(directory, `recording-${id}.mp4`),
203
+ }
204
+ }
205
+
180
206
  async function terminate(child: Bun.Subprocess) {
181
207
  if (child.exitCode !== null) return
182
208
  child.kill("SIGTERM")
@@ -0,0 +1,16 @@
1
+ import { mkdir } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { join, resolve } from "node:path"
4
+
5
+ export function mediaDirectory() {
6
+ return resolve(
7
+ process.env.OPENCODE_DRIVE_MEDIA_DIR ??
8
+ join(tmpdir(), "opencode-drive", "output"),
9
+ )
10
+ }
11
+
12
+ export async function ensureMediaDirectory() {
13
+ const directory = mediaDirectory()
14
+ await mkdir(directory, { recursive: true })
15
+ return directory
16
+ }
@@ -2,6 +2,6 @@ import { request } from "./control.js"
2
2
  import { resolveInstance } from "./registry.js"
3
3
 
4
4
  export async function restart(name?: string) {
5
- await request((await resolveInstance(name)).control, "restart")
6
- console.log("success")
5
+ const recording = await request((await resolveInstance(name)).control, "restart")
6
+ console.log(recording ?? "success")
7
7
  }
package/src/cli/script.ts CHANGED
@@ -24,6 +24,7 @@ export async function runScript(
24
24
  artifacts: string,
25
25
  endpoints: { readonly ui: string; readonly backend: string },
26
26
  signal: AbortSignal,
27
+ onScreenshot?: (path: string) => void,
27
28
  ) {
28
29
  const module: { readonly default?: unknown } = await import(
29
30
  pathToFileURL(resolve(file)).href
@@ -31,7 +32,7 @@ export async function runScript(
31
32
  const script = module.default
32
33
  if (!isDriveScript(script))
33
34
  throw new Error("script must default-export a function")
34
- const ui = await connectSimulation({ url: endpoints.ui })
35
+ const ui = await connectSimulation({ url: endpoints.ui, onScreenshot })
35
36
  const backend = await connectBackendSimulation({
36
37
  url: endpoints.backend,
37
38
  }).catch((error) => {
package/src/cli/send.ts CHANGED
@@ -5,13 +5,11 @@ import { resolveInstance } from "./registry.js"
5
5
  export async function send(options: SendOptions) {
6
6
  if (options.commands.length === 0)
7
7
  throw new Error("send requires at least one --command.ui.* flag")
8
- const result = await executeCommands(
9
- (await resolveInstance(options.name)).endpoints.ui,
10
- options.commands,
11
- )
8
+ const instance = await resolveInstance(options.name)
9
+ const result = await executeCommands(instance.endpoints.ui, options.commands)
12
10
  if (
13
11
  options.commands.length === 1 &&
14
- ["ui.screenshot", "ui.end-record"].includes(
12
+ ["ui.screenshot", "ui.recording.finish"].includes(
15
13
  options.commands[0]?.operation ?? "",
16
14
  )
17
15
  ) {
@@ -20,7 +18,7 @@ export async function send(options: SendOptions) {
20
18
  }
21
19
  if (
22
20
  options.commands.length === 1 &&
23
- ["ui.state", "ui.start-record"].includes(
21
+ ["ui.state"].includes(
24
22
  options.commands[0]?.operation ?? "",
25
23
  )
26
24
  ) {
package/src/cli/start.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { launchInstance } from "./instance.js"
2
2
  import { mkdir, rm } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
+ import { connectSimulation } from "../client/index.js"
5
+ import { exportRecording } from "../recording/index.js"
4
6
  import { connectMockBackend } from "./mock-backend.js"
5
7
  import { createResponseSettings } from "./response-generator.js"
6
8
  import { runScript } from "./script.js"
@@ -27,6 +29,7 @@ export async function start(options: StartOptions) {
27
29
  state: options.state,
28
30
  scripted: options.script !== undefined,
29
31
  visible: options.visible,
32
+ record: options.record,
30
33
  })
31
34
  await register({
32
35
  version: 1,
@@ -43,13 +46,48 @@ export async function start(options: StartOptions) {
43
46
  await instance.stop()
44
47
  throw error
45
48
  })
46
- const interrupt = () => void instance.stop()
47
49
  let completed = false
48
50
  let current: ReturnType<typeof run> | undefined
49
- let restarting: Promise<void> | undefined
51
+ let restarting: Promise<string | undefined> | undefined
50
52
  let stopping = false
53
+ const screenshots: string[] = []
54
+ let driveReady = false
55
+ let recording: Promise<string | undefined> | undefined
56
+ const finishCurrentRecording = (onProgress?: (percent: number) => void) => {
57
+ if (!options.record || options.visible || !driveReady)
58
+ return Promise.resolve(undefined)
59
+ recording ??= finishRecording(instance, onProgress)
60
+ return recording
61
+ }
62
+ const interrupt = () => {
63
+ stopping = true
64
+ current?.abort.abort(new Error("opencode-drive interrupted"))
65
+ void finishCurrentRecording()
66
+ .catch((error) =>
67
+ process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`),
68
+ )
69
+ .finally(() => instance.stop())
70
+ }
51
71
  process.once("SIGINT", interrupt)
52
72
  process.once("SIGTERM", interrupt)
73
+ const stopInstance = async (onProgress?: (percent: number) => void) => {
74
+ try {
75
+ const output = await finishCurrentRecording(onProgress)
76
+ return {
77
+ ...(output ? { recording: output } : {}),
78
+ screenshots: [...screenshots],
79
+ }
80
+ } finally {
81
+ stopping = true
82
+ current?.abort.abort(new Error("opencode-drive stopped"))
83
+ await instance.stop()
84
+ }
85
+ }
86
+ const completeScript = async () => {
87
+ completed = true
88
+ const result = await stopInstance()
89
+ for (const screenshot of result.screenshots) console.log(screenshot)
90
+ }
53
91
  let closeControl: (() => Promise<void>) | undefined
54
92
  try {
55
93
  closeControl = await listenControl(controlPath(options.name), {
@@ -57,34 +95,45 @@ export async function start(options: StartOptions) {
57
95
  if (restarting) return restarting
58
96
  restarting = (async () => {
59
97
  await markStarting(options.name, process.pid)
98
+ const output = await finishCurrentRecording()
60
99
  const previous = current
61
100
  previous?.abort.abort(new Error("script restarted"))
62
101
  await previous?.promise.catch(() => undefined)
102
+ driveReady = false
63
103
  await instance.restart()
64
- current = run(options, instance, responses)
104
+ recording = undefined
105
+ current = run(options, instance, responses, (path) => screenshots.push(path))
65
106
  await current.ready
107
+ driveReady = true
66
108
  await markReady(options.name, process.pid)
109
+ return output
67
110
  })().finally(() => {
68
111
  restarting = undefined
69
112
  })
70
113
  return restarting
71
114
  },
72
- stop: async () => {
73
- stopping = true
74
- current?.abort.abort(new Error("opencode-drive stopped"))
75
- await instance.stop()
76
- },
115
+ stop: stopInstance,
77
116
  responses: async (input) => {
78
117
  if (options.script)
79
118
  throw new Error("responses are unavailable when --script owns the simulation backend")
80
119
  return responses.update(input)
81
120
  },
82
121
  })
83
- current = run(options, instance, responses)
122
+ current = run(options, instance, responses, (path) => screenshots.push(path))
84
123
  await current.ready
124
+ driveReady = true
85
125
  await markReady(options.name, process.pid)
86
126
  if (options.visible) {
87
- const status = await instance.wait()
127
+ const result = options.script
128
+ ? await Promise.race([
129
+ current.promise.then(() => ({ script: true as const })),
130
+ instance.wait().then((status) => ({ script: false as const, status })),
131
+ ])
132
+ : { script: false as const, status: await instance.wait() }
133
+ if (result.script) {
134
+ await completeScript()
135
+ }
136
+ const status = result.script ? await instance.wait() : result.status
88
137
  if (status !== 0 && !stopping) process.exitCode = status
89
138
  return
90
139
  }
@@ -97,6 +146,10 @@ export async function start(options: StartOptions) {
97
146
  continue
98
147
  }
99
148
  if (active !== current) continue
149
+ if (options.script) {
150
+ await completeScript()
151
+ break
152
+ }
100
153
  completed = true
101
154
  break
102
155
  }
@@ -104,12 +157,43 @@ export async function start(options: StartOptions) {
104
157
  process.off("SIGINT", interrupt)
105
158
  process.off("SIGTERM", interrupt)
106
159
  current?.abort.abort(new Error("opencode-drive stopped"))
160
+ const recordingPath = await finishCurrentRecording().catch((error) => {
161
+ process.stderr.write(`opencode-drive: failed to export recording: ${error}\n`)
162
+ return undefined
163
+ })
107
164
  await closeControl?.()
108
165
  await instance.stop()
109
166
  await unregister(options.name, process.pid)
110
167
  if (options.script && !options.visible)
111
168
  report(instance, completed ? "completed" : undefined)
169
+ if (options.script && recordingPath)
170
+ console.error(`opencode-drive: recording ${recordingPath}`)
171
+ }
172
+ }
173
+
174
+ async function finishRecording(
175
+ instance: Awaited<ReturnType<typeof launchInstance>>,
176
+ onProgress?: (percent: number) => void,
177
+ ) {
178
+ const expected = instance.recording
179
+ if (!expected) throw new Error("recording was not enabled for this instance")
180
+ let timeline: string
181
+ if (instance.child.exitCode !== null) {
182
+ timeline = expected.timeline
183
+ } else {
184
+ const ui = await connectSimulation({ url: instance.endpoints.ui, timeout: 60_000 })
185
+ try {
186
+ timeline = await ui.finishRecording()
187
+ } finally {
188
+ ui.close()
189
+ }
112
190
  }
191
+ if (timeline !== expected.timeline)
192
+ throw new Error(`OpenCode returned an unexpected recording path: ${timeline}`)
193
+ if (!(await Bun.file(timeline).exists()))
194
+ throw new Error(`OpenCode recording timeline was not created: ${timeline}`)
195
+ await exportRecording(timeline, expected.video, { onProgress })
196
+ return expected.video
113
197
  }
114
198
 
115
199
  async function startDetached(options: StartOptions) {
@@ -130,6 +214,7 @@ async function startDetached(options: StartOptions) {
130
214
  ...(options.script ? ["--script", options.script] : []),
131
215
  ...(options.dev ? ["--dev", options.dev] : []),
132
216
  ...(options.state ? ["--state", options.state] : []),
217
+ ...(options.record ? ["--record"] : []),
133
218
  ...(options.command.length ? ["--", ...options.command] : []),
134
219
  ],
135
220
  {
@@ -176,6 +261,7 @@ function run(
176
261
  options: StartOptions,
177
262
  instance: Awaited<ReturnType<typeof launchInstance>>,
178
263
  responses: ReturnType<typeof createResponseSettings>,
264
+ onScreenshot: (path: string) => void,
179
265
  ) {
180
266
  const abort = new AbortController()
181
267
  const child = instance.child
@@ -194,18 +280,21 @@ function run(
194
280
  instance.artifacts,
195
281
  instance.endpoints,
196
282
  abort.signal,
283
+ onScreenshot,
197
284
  )
198
285
  ready()
199
- await script
200
286
  if (options.visible) {
201
- await Promise.race([
202
- child.exited,
203
- new Promise<void>((resolve) =>
204
- abort.signal.addEventListener("abort", () => resolve(), {
205
- once: true,
206
- }),
207
- ),
208
- ])
287
+ await script
288
+ return
289
+ }
290
+ const result = await Promise.race([
291
+ script.then(() => ({ script: true as const })),
292
+ child.exited.then((status) => ({ script: false as const, status })),
293
+ ])
294
+ if (!result.script) {
295
+ abort.abort(new Error(`OpenCode exited with status ${result.status}`))
296
+ await script.catch(() => undefined)
297
+ if (result.status !== 0) process.exitCode = result.status
209
298
  }
210
299
  return
211
300
  }