opencode-drive 0.1.7 → 0.1.9
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 -34
- package/src/cli/instance.ts +74 -52
- package/src/cli/media.ts +16 -0
- package/src/cli/restart.ts +2 -2
- package/src/cli/script.ts +33 -8
- package/src/cli/send.ts +4 -6
- package/src/cli/start.ts +119 -24
- package/src/cli/stop.ts +11 -4
- package/src/cli/types.ts +1 -1
- 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/index.ts +6 -1
- 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.9",
|
|
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,14 +38,13 @@ 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"),
|
|
44
47
|
),
|
|
45
|
-
state: Flag.string("state").pipe(
|
|
46
|
-
Flag.optional,
|
|
47
|
-
Flag.withDescription("Simulation snapshot containing files/"),
|
|
48
|
-
),
|
|
49
48
|
},
|
|
50
49
|
(config) =>
|
|
51
50
|
execute(() =>
|
|
@@ -100,32 +99,6 @@ const screenshotCommand = Command.make("screenshot", { name }, (config) =>
|
|
|
100
99
|
),
|
|
101
100
|
).pipe(Command.withDescription("Take a screenshot and print its path"))
|
|
102
101
|
|
|
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
102
|
const restartCommand = Command.make("restart", { name }, (config) =>
|
|
130
103
|
execute(() => restart(Option.getOrUndefined(config.name))),
|
|
131
104
|
).pipe(
|
|
@@ -175,8 +148,6 @@ const root = Command.make("opencode-drive").pipe(
|
|
|
175
148
|
startCommand,
|
|
176
149
|
sendCommand,
|
|
177
150
|
screenshotCommand,
|
|
178
|
-
startRecordingCommand,
|
|
179
|
-
endRecordingCommand,
|
|
180
151
|
listCommand,
|
|
181
152
|
responsesCommand,
|
|
182
153
|
logsCommand,
|
|
@@ -196,8 +167,8 @@ function toStartOptions(
|
|
|
196
167
|
readonly name: string
|
|
197
168
|
readonly daemon: boolean
|
|
198
169
|
readonly visible: boolean
|
|
170
|
+
readonly record: boolean
|
|
199
171
|
readonly dev: Option.Option<string>
|
|
200
|
-
readonly state: Option.Option<string>
|
|
201
172
|
},
|
|
202
173
|
commands: ReadonlyArray<DriveCommand>,
|
|
203
174
|
app: ReadonlyArray<string>,
|
|
@@ -210,8 +181,8 @@ function toStartOptions(
|
|
|
210
181
|
daemon: config.daemon,
|
|
211
182
|
script: Option.getOrUndefined(config.script),
|
|
212
183
|
visible: config.visible,
|
|
184
|
+
record: config.record,
|
|
213
185
|
dev: Option.getOrUndefined(config.dev),
|
|
214
|
-
state: Option.getOrUndefined(config.state),
|
|
215
186
|
command: app,
|
|
216
187
|
}
|
|
217
188
|
if (options.dev !== undefined && app.length > 0)
|
package/src/cli/instance.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { mkdir
|
|
1
|
+
import { mkdir } 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"
|
|
5
|
+
import type { DriveScriptSetup } from "./script.js"
|
|
4
6
|
|
|
5
7
|
export interface LaunchOptions {
|
|
6
8
|
readonly name: string
|
|
7
9
|
readonly command?: ReadonlyArray<string>
|
|
8
10
|
readonly dev?: string
|
|
9
|
-
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>>
|
|
15
|
+
readonly setup?: DriveScriptSetup
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export async function launchInstance(options: LaunchOptions) {
|
|
@@ -22,6 +25,7 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
22
25
|
backend: `ws://127.0.0.1:${await freePort()}`,
|
|
23
26
|
}
|
|
24
27
|
const drive = join(artifacts, "drive")
|
|
28
|
+
const media = await ensureMediaDirectory()
|
|
25
29
|
await Promise.all([
|
|
26
30
|
mkdir(logs, { recursive: true }),
|
|
27
31
|
mkdir(drive, { recursive: true }),
|
|
@@ -30,66 +34,71 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
30
34
|
mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
|
|
31
35
|
mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
|
|
32
36
|
])
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
37
|
+
let recording = options.record ? recordingPaths(media) : undefined
|
|
38
|
+
const writeDriveManifest = () =>
|
|
39
|
+
Bun.write(
|
|
40
|
+
join(drive, `${options.name}.json`),
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
endpoints,
|
|
44
|
+
...(recording ? { recording: { timeline: recording.timeline } } : {}),
|
|
45
|
+
},
|
|
46
|
+
undefined,
|
|
47
|
+
2,
|
|
48
|
+
)}\n`,
|
|
49
|
+
)
|
|
50
|
+
await writeDriveManifest()
|
|
51
|
+
const files = join(artifacts, "files")
|
|
52
|
+
await Promise.all([
|
|
53
|
+
mkdir(join(files, ".git"), { recursive: true }),
|
|
54
|
+
mkdir(join(files, ".opencode"), { recursive: true }),
|
|
55
|
+
mkdir(join(files, "src"), { recursive: true }),
|
|
56
|
+
])
|
|
57
|
+
await Promise.all([
|
|
58
|
+
Bun.write(
|
|
59
|
+
join(files, ".opencode", "opencode.jsonc"),
|
|
60
|
+
`${JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
model: "simulation/gpt-sim-model",
|
|
63
|
+
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
64
|
+
providers: {
|
|
65
|
+
simulation: {
|
|
66
|
+
name: "Simulation",
|
|
67
|
+
package: "aisdk:@ai-sdk/openai-compatible",
|
|
68
|
+
settings: { baseURL: "https://api.openai.com/v1" },
|
|
69
|
+
request: { body: { apiKey: "sim-key" } },
|
|
70
|
+
models: {
|
|
71
|
+
"gpt-sim-model": {
|
|
72
|
+
name: "Simulated Model",
|
|
73
|
+
capabilities: {
|
|
74
|
+
tools: true,
|
|
75
|
+
input: ["text"],
|
|
76
|
+
output: ["text"],
|
|
69
77
|
},
|
|
78
|
+
limit: { context: 128000, output: 16000 },
|
|
70
79
|
},
|
|
71
80
|
},
|
|
72
81
|
},
|
|
73
82
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
),
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
},
|
|
84
|
+
undefined,
|
|
85
|
+
2,
|
|
86
|
+
)}\n`,
|
|
87
|
+
),
|
|
88
|
+
Bun.write(
|
|
89
|
+
join(files, "src", "garden.js"),
|
|
90
|
+
'export function greet(name) {\n return `Hello, ${name}.`\n}\n',
|
|
91
|
+
),
|
|
92
|
+
])
|
|
93
|
+
await options.setup?.({ directory: files })
|
|
84
94
|
const environment = cleanEnv({
|
|
85
95
|
...process.env,
|
|
86
96
|
...options.env,
|
|
87
97
|
OPENCODE_SIMULATE: "1",
|
|
88
|
-
OPENCODE_SIMULATE_STATE: state,
|
|
89
98
|
DRIVE_REGISTRY_DIR: drive,
|
|
90
99
|
OPENCODE_DRIVE: options.name,
|
|
91
100
|
OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
|
|
92
|
-
OPENCODE_CONFIG_DIR: join(
|
|
101
|
+
OPENCODE_CONFIG_DIR: join(files, ".opencode"),
|
|
93
102
|
OPENCODE_DB: ":memory:",
|
|
94
103
|
OPENCODE_LOG_LEVEL: !options.visible
|
|
95
104
|
? "INFO"
|
|
@@ -101,13 +110,13 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
101
110
|
XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
|
|
102
111
|
})
|
|
103
112
|
const command = options.dev
|
|
104
|
-
? await prepareDev(
|
|
113
|
+
? await prepareDev(files, options.dev)
|
|
105
114
|
: options.command?.length
|
|
106
115
|
? [...options.command]
|
|
107
116
|
: ["opencode2"]
|
|
108
117
|
const spawn = () =>
|
|
109
118
|
Bun.spawn(command, {
|
|
110
|
-
cwd:
|
|
119
|
+
cwd: files,
|
|
111
120
|
env: environment,
|
|
112
121
|
stdin: options.visible ? "inherit" : "ignore",
|
|
113
122
|
stdout: !options.visible
|
|
@@ -124,6 +133,9 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
124
133
|
artifacts,
|
|
125
134
|
logs,
|
|
126
135
|
endpoints,
|
|
136
|
+
get recording() {
|
|
137
|
+
return recording
|
|
138
|
+
},
|
|
127
139
|
get child() {
|
|
128
140
|
return child
|
|
129
141
|
},
|
|
@@ -143,6 +155,8 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
143
155
|
if (restarting) return restarting
|
|
144
156
|
restarting = (async () => {
|
|
145
157
|
await terminate(child)
|
|
158
|
+
recording = options.record ? recordingPaths(media) : undefined
|
|
159
|
+
await writeDriveManifest()
|
|
146
160
|
child = spawn()
|
|
147
161
|
await Promise.all([
|
|
148
162
|
waitForWebSocket(endpoints.ui, child.exited, 60_000),
|
|
@@ -177,6 +191,14 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
177
191
|
}
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
function recordingPaths(directory: string) {
|
|
195
|
+
const id = crypto.randomUUID()
|
|
196
|
+
return {
|
|
197
|
+
timeline: join(directory, `recording-${id}.jsonl`),
|
|
198
|
+
video: join(directory, `recording-${id}.mp4`),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
180
202
|
async function terminate(child: Bun.Subprocess) {
|
|
181
203
|
if (child.exitCode !== null) return
|
|
182
204
|
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
|
@@ -15,23 +15,44 @@ export interface ScriptContext {
|
|
|
15
15
|
|
|
16
16
|
export type DriveScript = (context: ScriptContext) => void | Promise<void>
|
|
17
17
|
|
|
18
|
+
export interface ScriptSetupContext {
|
|
19
|
+
readonly directory: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DriveScriptSetup = (
|
|
23
|
+
context: ScriptSetupContext,
|
|
24
|
+
) => void | Promise<void>
|
|
25
|
+
|
|
26
|
+
export interface LoadedDriveScript {
|
|
27
|
+
readonly run: DriveScript
|
|
28
|
+
readonly setup?: DriveScriptSetup
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export function defineScript(script: DriveScript) {
|
|
19
32
|
return script
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
export async function loadScript(file: string): Promise<LoadedDriveScript> {
|
|
36
|
+
const module: { readonly default?: unknown; readonly setup?: unknown } =
|
|
37
|
+
await import(pathToFileURL(resolve(file)).href)
|
|
38
|
+
if (!isDriveScript(module.default))
|
|
39
|
+
throw new Error("script must default-export a function")
|
|
40
|
+
if (module.setup !== undefined && !isDriveScriptSetup(module.setup))
|
|
41
|
+
throw new Error("script setup export must be a function")
|
|
42
|
+
return {
|
|
43
|
+
run: module.default,
|
|
44
|
+
...(module.setup === undefined ? {} : { setup: module.setup }),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
export async function runScript(
|
|
23
|
-
|
|
49
|
+
script: DriveScript,
|
|
24
50
|
artifacts: string,
|
|
25
51
|
endpoints: { readonly ui: string; readonly backend: string },
|
|
26
52
|
signal: AbortSignal,
|
|
53
|
+
onScreenshot?: (path: string) => void,
|
|
27
54
|
) {
|
|
28
|
-
const
|
|
29
|
-
pathToFileURL(resolve(file)).href
|
|
30
|
-
)
|
|
31
|
-
const script = module.default
|
|
32
|
-
if (!isDriveScript(script))
|
|
33
|
-
throw new Error("script must default-export a function")
|
|
34
|
-
const ui = await connectSimulation({ url: endpoints.ui })
|
|
55
|
+
const ui = await connectSimulation({ url: endpoints.ui, onScreenshot })
|
|
35
56
|
const backend = await connectBackendSimulation({
|
|
36
57
|
url: endpoints.backend,
|
|
37
58
|
}).catch((error) => {
|
|
@@ -75,3 +96,7 @@ async function waitForEditor(ui: SimulationClient, signal: AbortSignal) {
|
|
|
75
96
|
function isDriveScript(value: unknown): value is DriveScript {
|
|
76
97
|
return typeof value === "function"
|
|
77
98
|
}
|
|
99
|
+
|
|
100
|
+
function isDriveScriptSetup(value: unknown): value is DriveScriptSetup {
|
|
101
|
+
return typeof value === "function"
|
|
102
|
+
}
|
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
|
) {
|