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 +3 -1
- package/package.json +9 -3
- package/src/cli/commands.ts +5 -9
- package/src/cli/control.ts +55 -11
- package/src/cli/index.ts +5 -28
- package/src/cli/instance.ts +30 -4
- package/src/cli/media.ts +16 -0
- package/src/cli/restart.ts +2 -2
- package/src/cli/script.ts +2 -1
- package/src/cli/send.ts +4 -6
- package/src/cli/start.ts +108 -19
- package/src/cli/stop.ts +11 -4
- package/src/cli/types.ts +1 -0
- package/src/client/client.ts +33 -18
- package/src/client/protocol.ts +13 -11
- package/src/experimental/reproduce-stale-exploring-empty.ts +102 -0
- package/src/recording/decode.ts +93 -0
- package/src/recording/export.ts +113 -0
- package/src/recording/index.ts +13 -0
- package/src/recording/render.ts +80 -0
- package/src/recording/replay.ts +55 -0
- package/src/recording/terminal.ts +124 -0
- package/src/recording/types.ts +50 -0
- package/src/experimental/reproduce-stale-running-visible.ts +0 -60
- package/src/experimental/reproduce-stale-running.ts +0 -26
- package/src/experimental/stale-running-driver.ts +0 -106
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.
|
|
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.
|
|
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
|
|
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": {
|
package/src/cli/commands.ts
CHANGED
|
@@ -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.
|
|
26
|
-
"ui.end-record": {
|
|
25
|
+
"ui.recording.finish": {
|
|
27
26
|
value: false,
|
|
28
|
-
description: "
|
|
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.
|
|
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.
|
|
146
|
-
return ui.
|
|
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
|
}
|
package/src/cli/control.ts
CHANGED
|
@@ -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<
|
|
12
|
-
readonly stop: () => Promise<
|
|
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
|
-
|
|
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(
|
|
63
|
+
export async function request(
|
|
64
|
+
path: string,
|
|
65
|
+
command: "restart",
|
|
66
|
+
) {
|
|
58
67
|
const response = await send(path, command)
|
|
59
|
-
if (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
|
-
},
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/cli/instance.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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")
|
package/src/cli/media.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/restart.ts
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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.
|
|
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"
|
|
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<
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
}
|