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 +68 -58
- package/package.json +2 -2
- package/src/cli/api.ts +5 -0
- package/src/cli/commands.ts +59 -109
- package/src/cli/index.ts +13 -2
- package/src/cli/instance.ts +89 -12
- package/src/cli/restart.ts +70 -0
- package/src/cli/send.ts +2 -2
- package/src/cli/start.ts +45 -3
- package/src/client/backend.ts +0 -5
- package/src/client/client.ts +15 -31
- package/src/client/index.ts +0 -3
- package/src/client/protocol.ts +30 -27
- package/src/client/protocol.types.ts +68 -0
- package/src/experimental/campaign.ts +2 -3
- package/src/experimental/flow-driver.ts +0 -2
- package/src/experimental/flows/types.ts +0 -1
- package/src/experimental/reproduce-stale-running-visible.ts +1 -1
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 "
|
|
10
|
-
--command.
|
|
11
|
-
--command.
|
|
12
|
-
--command.state
|
|
23
|
+
--command.type "Explain this project in one sentence" \
|
|
24
|
+
--command.enter \
|
|
25
|
+
--command.screenshot
|
|
13
26
|
```
|
|
14
27
|
|
|
15
|
-
|
|
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
|
|
21
|
-
opencode2 --standalone
|
|
31
|
+
bunx opencode-drive api
|
|
22
32
|
```
|
|
23
33
|
|
|
24
|
-
|
|
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
|
|
37
|
+
bunx opencode-drive start --visible
|
|
30
38
|
```
|
|
31
39
|
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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 --
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
59
|
+
# Use cases
|
|
77
60
|
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
You can choose one or the other, or both! This allows the following use cases:
|
|
85
67
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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": "
|
|
10
|
+
"opencode-drive": "bin/opencode-drive"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": "./src/index.ts",
|
package/src/cli/api.ts
ADDED
package/src/cli/commands.ts
CHANGED
|
@@ -1,37 +1,30 @@
|
|
|
1
|
-
import
|
|
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
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
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 (
|
|
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
|
|
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 "
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 "
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 "
|
|
95
|
-
case "
|
|
96
|
-
const
|
|
97
|
-
|
|
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 "
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
if (
|
|
110
|
-
return (await backend()).chunk(
|
|
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
|
|
114
|
-
|
|
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
|
|
128
|
-
|
|
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.
|
|
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(
|
package/src/cli/instance.ts
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 && ["
|
|
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 && ["
|
|
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.
|
|
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
|
+
}
|
package/src/client/backend.ts
CHANGED
|
@@ -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
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -6,14 +6,16 @@ import {
|
|
|
6
6
|
const defaultPort = 40900
|
|
7
7
|
|
|
8
8
|
type Methods = {
|
|
9
|
-
readonly "ui.
|
|
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.
|
|
14
|
-
readonly "
|
|
15
|
-
readonly "
|
|
16
|
-
readonly "
|
|
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
|
-
|
|
127
|
-
return this.call("ui.
|
|
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.
|
|
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.
|
|
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.
|
|
150
|
+
return this.call("ui.enter")
|
|
153
151
|
}
|
|
154
152
|
|
|
155
153
|
pressArrow(direction: "up" | "down" | "left" | "right"): Promise<Frontend.State> {
|
|
156
|
-
return this.
|
|
154
|
+
return this.call("ui.arrow", { direction })
|
|
157
155
|
}
|
|
158
156
|
|
|
159
157
|
focus(target: number): Promise<Frontend.State> {
|
|
160
|
-
return this.
|
|
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.
|
|
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 ─────────────────────────────────────────────────────────
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
package/src/client/protocol.ts
CHANGED
|
@@ -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("
|
|
63
|
-
Schema.Struct({ type: Schema.Literal("
|
|
64
|
-
Schema.Struct({ type: Schema.Literal("
|
|
65
|
-
Schema.Struct({ type: Schema.Literal("
|
|
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
|
|
96
|
-
export type
|
|
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
|
|
105
|
-
export interface
|
|
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.
|
|
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.
|
|
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"
|
|
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",
|
|
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;
|
|
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
|
}
|
|
@@ -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",
|
|
41
|
+
"/root/projects/opencode-latest/packages/cli/src/index.ts",
|
|
42
42
|
], {
|
|
43
43
|
cwd: path.resolve("."),
|
|
44
44
|
env: {
|