opencode-drive 0.1.2 → 0.1.3
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 +57 -38
- package/package.json +1 -1
- package/src/cli/commands.ts +101 -68
- package/src/cli/control.ts +43 -0
- package/src/cli/describe.ts +4 -5
- package/src/cli/index.ts +83 -110
- package/src/cli/instance.ts +193 -148
- package/src/cli/mock-backend.ts +25 -0
- package/src/cli/registry.ts +41 -83
- package/src/cli/restart.ts +3 -66
- package/src/cli/script.ts +66 -0
- package/src/cli/send.ts +19 -39
- package/src/cli/start.ts +93 -68
- package/src/cli/stop.ts +4 -28
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +94 -32
- package/src/client/protocol.ts +115 -29
- package/src/client/protocol.types.ts +17 -23
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -43
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
package/README.md
CHANGED
|
@@ -1,59 +1,71 @@
|
|
|
1
1
|
# opencode-drive
|
|
2
2
|
|
|
3
|
-
Drive
|
|
3
|
+
Drive real and simulated OpenCode instances through fixed local WebSocket ports.
|
|
4
|
+
|
|
5
|
+
## Skill
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
|
|
9
|
+
```
|
|
4
10
|
|
|
5
11
|
# Usage
|
|
6
12
|
|
|
7
|
-
**Start the default
|
|
13
|
+
**Start the default headless instance:**
|
|
8
14
|
|
|
9
15
|
```bash
|
|
10
16
|
bunx opencode-drive start
|
|
11
17
|
```
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
Run a local OpenCode checkout:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bunx opencode-drive start --dev ~/projects/opencode
|
|
17
25
|
```
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
## Send UI Commands
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
bunx opencode-drive send --name demo \
|
|
23
|
-
--command.type "Explain this project in one sentence" \
|
|
24
|
-
--command.enter \
|
|
25
|
-
--command.screenshot
|
|
26
|
-
```
|
|
29
|
+
`send` always connects to UI port `40900`:
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
```sh
|
|
32
|
+
bunx opencode-drive send \
|
|
33
|
+
--command.ui.type '{"text":"Explain this project"}' \
|
|
34
|
+
--command.ui.enter
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
bunx opencode-drive
|
|
36
|
+
bunx opencode-drive send --command.ui.state
|
|
37
|
+
bunx opencode-drive send --command.ui.screenshot
|
|
32
38
|
```
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
Run `bunx opencode-drive api` for the full UI command contract.
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
bunx opencode-drive start --visible
|
|
38
|
-
```
|
|
42
|
+
## Scripted Mode
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
|
|
41
45
|
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
bunx opencode-drive start --visible --dev .
|
|
46
|
+
```sh
|
|
47
|
+
bunx opencode-drive start --script ./drive.ts
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
Add `--visible` to watch the TUI while the script runs. Visible starts remain active after a script finishes. Restart the visible OpenCode child and rerun its script with `bunx opencode-drive restart`.
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
bunx opencode-drive start --name demo -- opencode2 --standalone
|
|
51
|
-
```
|
|
52
|
+
Only one visible instance can be controlled this way. Headless runs exit when their script completes and do not support restart.
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
The module must default-export a function receiving connected clients and the run's artifact directory:
|
|
54
55
|
|
|
55
|
-
```
|
|
56
|
-
|
|
56
|
+
```ts
|
|
57
|
+
import { defineScript } from "opencode-drive"
|
|
58
|
+
|
|
59
|
+
export default defineScript(async ({ ui, backend }) => {
|
|
60
|
+
await backend.attach(async (request) => {
|
|
61
|
+
await backend.chunk(request.id, [{ type: "textDelta", text: "Hello" }])
|
|
62
|
+
await backend.finish(request.id)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await ui.typeText("Say hello")
|
|
66
|
+
await ui.pressEnter()
|
|
67
|
+
await ui.screenshot()
|
|
68
|
+
})
|
|
57
69
|
```
|
|
58
70
|
|
|
59
71
|
# Use cases
|
|
@@ -65,36 +77,43 @@ There are two different modes OpenCode can run in:
|
|
|
65
77
|
|
|
66
78
|
You can choose one or the other, or both! This allows the following use cases:
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
## You want to develop opencode
|
|
69
81
|
|
|
70
82
|
You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
|
|
71
83
|
|
|
72
84
|
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
85
|
|
|
74
|
-
To do this,
|
|
86
|
+
To do this, all you have to do is pass `OPENCODE_DRIVE=1` when running opencode:
|
|
75
87
|
|
|
76
88
|
```sh
|
|
89
|
+
# In the opencode repo
|
|
90
|
+
OPENCODE_DRIVE=1 bun run dev
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### UI work in simulated mode
|
|
94
|
+
|
|
95
|
+
If you are doing UI work, you may want to run it in simulated mode. You can do that with this:
|
|
96
|
+
|
|
97
|
+
```
|
|
77
98
|
bunx opencode-drive start --dev . --visible
|
|
78
99
|
```
|
|
79
100
|
|
|
80
|
-
|
|
101
|
+
The nice thing about this is OpenCode can drive it into the states you are interested in. Additionally, its able to restart the UI itself. The server will still be running in the background so this only restarts the UI:
|
|
81
102
|
|
|
82
103
|
```sh
|
|
83
104
|
bunx opencode-drive restart
|
|
84
105
|
```
|
|
85
106
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
2. You want opencode to develop itself
|
|
107
|
+
## You want opencode to develop itself
|
|
89
108
|
|
|
90
109
|
OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
|
|
91
110
|
|
|
92
111
|
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
112
|
|
|
94
|
-
|
|
113
|
+
## You want to share reproducible steps
|
|
95
114
|
|
|
96
115
|
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
116
|
|
|
98
|
-
|
|
117
|
+
## You want to run it a billion times and assert properties
|
|
99
118
|
|
|
100
119
|
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
package/src/cli/commands.ts
CHANGED
|
@@ -1,25 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { DriveCommand
|
|
1
|
+
import { connectSimulation, Frontend } from "../client/index.js"
|
|
2
|
+
import type { DriveCommand } from "./types.js"
|
|
3
3
|
|
|
4
4
|
export const commandInfo = {
|
|
5
5
|
"ui.type": { value: true, description: "Type text using JSON params" },
|
|
6
6
|
"ui.press": { value: true, description: "Press a key using JSON params" },
|
|
7
7
|
"ui.enter": { value: false, description: "Press Enter" },
|
|
8
|
-
"ui.arrow": {
|
|
9
|
-
|
|
8
|
+
"ui.arrow": {
|
|
9
|
+
value: true,
|
|
10
|
+
description: "Press an arrow key using JSON params",
|
|
11
|
+
},
|
|
12
|
+
"ui.focus": {
|
|
13
|
+
value: true,
|
|
14
|
+
description: "Focus an element using JSON params",
|
|
15
|
+
},
|
|
10
16
|
"ui.click": { value: true, description: "Click using JSON params" },
|
|
11
|
-
"ui.screenshot": {
|
|
12
|
-
|
|
17
|
+
"ui.screenshot": {
|
|
18
|
+
value: false,
|
|
19
|
+
description: "Take a screenshot and return its path",
|
|
20
|
+
},
|
|
21
|
+
"ui.state": {
|
|
22
|
+
value: false,
|
|
23
|
+
description: "Return focus, elements, and available UI actions",
|
|
24
|
+
},
|
|
13
25
|
"ui.start-record": { value: false, description: "Start recording the UI" },
|
|
14
|
-
"ui.end-record": {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"llm.disconnect": { value: true, description: "Disconnect a simulated LLM exchange" },
|
|
26
|
+
"ui.end-record": {
|
|
27
|
+
value: false,
|
|
28
|
+
description: "Stop recording and return the recording path",
|
|
29
|
+
},
|
|
19
30
|
} as const
|
|
20
31
|
|
|
21
32
|
export function commandAcceptsValue(operation: string) {
|
|
22
|
-
if (operation
|
|
33
|
+
if (operation === "ui.type") return commandInfo[operation].value
|
|
34
|
+
if (operation === "ui.press") return commandInfo[operation].value
|
|
35
|
+
if (operation === "ui.enter") return commandInfo[operation].value
|
|
36
|
+
if (operation === "ui.arrow") return commandInfo[operation].value
|
|
37
|
+
if (operation === "ui.focus") return commandInfo[operation].value
|
|
38
|
+
if (operation === "ui.click") return commandInfo[operation].value
|
|
39
|
+
if (operation === "ui.screenshot") return commandInfo[operation].value
|
|
40
|
+
if (operation === "ui.state") return commandInfo[operation].value
|
|
41
|
+
if (operation === "ui.start-record") return commandInfo[operation].value
|
|
42
|
+
if (operation === "ui.end-record") return commandInfo[operation].value
|
|
23
43
|
throw new Error(`unknown drive command "${operation}"`)
|
|
24
44
|
}
|
|
25
45
|
|
|
@@ -27,31 +47,30 @@ export function commandNames() {
|
|
|
27
47
|
return Object.keys(commandInfo).sort()
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
export async function executeCommands(
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} = {}
|
|
35
|
-
const ui = async () => (clients.ui ??= await connectSimulation({ url: manifest.endpoints.ui }))
|
|
36
|
-
const backend = async () => (clients.backend ??= await connectBackendSimulation({ url: manifest.endpoints.backend }))
|
|
37
|
-
const results: Array<{ readonly command: string; readonly result: unknown }> = []
|
|
50
|
+
export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
|
|
51
|
+
const ui = await connectSimulation({ url: endpoint })
|
|
52
|
+
const results: Array<{ readonly command: string; readonly result: unknown }> =
|
|
53
|
+
[]
|
|
38
54
|
try {
|
|
39
|
-
for (const command of commands)
|
|
40
|
-
results.push({
|
|
41
|
-
|
|
42
|
-
|
|
55
|
+
for (const command of commands)
|
|
56
|
+
results.push({
|
|
57
|
+
command: command.operation,
|
|
58
|
+
result: await execute(command, ui),
|
|
59
|
+
})
|
|
60
|
+
return { results }
|
|
43
61
|
} catch (error) {
|
|
44
|
-
throw new CommandBatchError(
|
|
62
|
+
throw new CommandBatchError(results, error)
|
|
45
63
|
} finally {
|
|
46
|
-
|
|
47
|
-
clients.backend?.close()
|
|
64
|
+
ui.close()
|
|
48
65
|
}
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
export class CommandBatchError extends Error {
|
|
52
69
|
constructor(
|
|
53
|
-
readonly
|
|
54
|
-
|
|
70
|
+
readonly results: ReadonlyArray<{
|
|
71
|
+
readonly command: string
|
|
72
|
+
readonly result: unknown
|
|
73
|
+
}>,
|
|
55
74
|
readonly reason: unknown,
|
|
56
75
|
) {
|
|
57
76
|
super(reason instanceof Error ? reason.message : String(reason))
|
|
@@ -61,62 +80,76 @@ export class CommandBatchError extends Error {
|
|
|
61
80
|
|
|
62
81
|
async function execute(
|
|
63
82
|
command: DriveCommand,
|
|
64
|
-
ui:
|
|
65
|
-
backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
|
|
83
|
+
ui: Awaited<ReturnType<typeof connectSimulation>>,
|
|
66
84
|
) {
|
|
67
85
|
switch (command.operation) {
|
|
68
86
|
case "ui.type": {
|
|
69
|
-
const request = Frontend.decodeRequest({
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
const request = Frontend.decodeRequest({
|
|
88
|
+
jsonrpc: "2.0",
|
|
89
|
+
method: "ui.type",
|
|
90
|
+
params: json(required(command)),
|
|
91
|
+
})
|
|
92
|
+
if (request.method !== "ui.type")
|
|
93
|
+
throw new Error("invalid ui.type params")
|
|
94
|
+
return ui.typeText(request.params.text)
|
|
72
95
|
}
|
|
73
96
|
case "ui.press": {
|
|
74
|
-
const request = Frontend.decodeRequest({
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
const request = Frontend.decodeRequest({
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
method: "ui.press",
|
|
100
|
+
params: json(required(command)),
|
|
101
|
+
})
|
|
102
|
+
if (request.method !== "ui.press")
|
|
103
|
+
throw new Error("invalid ui.press params")
|
|
104
|
+
return ui.pressKey(request.params.key, request.params.modifiers)
|
|
77
105
|
}
|
|
78
|
-
case "ui.enter":
|
|
106
|
+
case "ui.enter":
|
|
107
|
+
return ui.pressEnter()
|
|
79
108
|
case "ui.arrow": {
|
|
80
|
-
const request = Frontend.decodeRequest({
|
|
81
|
-
|
|
82
|
-
|
|
109
|
+
const request = Frontend.decodeRequest({
|
|
110
|
+
jsonrpc: "2.0",
|
|
111
|
+
method: "ui.arrow",
|
|
112
|
+
params: json(required(command)),
|
|
113
|
+
})
|
|
114
|
+
if (request.method !== "ui.arrow")
|
|
115
|
+
throw new Error("invalid ui.arrow params")
|
|
116
|
+
return ui.pressArrow(request.params.direction)
|
|
83
117
|
}
|
|
84
118
|
case "ui.focus": {
|
|
85
|
-
const request = Frontend.decodeRequest({
|
|
86
|
-
|
|
87
|
-
|
|
119
|
+
const request = Frontend.decodeRequest({
|
|
120
|
+
jsonrpc: "2.0",
|
|
121
|
+
method: "ui.focus",
|
|
122
|
+
params: json(required(command)),
|
|
123
|
+
})
|
|
124
|
+
if (request.method !== "ui.focus")
|
|
125
|
+
throw new Error("invalid ui.focus params")
|
|
126
|
+
return ui.focus(request.params.target)
|
|
88
127
|
}
|
|
89
128
|
case "ui.click": {
|
|
90
|
-
const request = Frontend.decodeRequest({
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
case "llm.pending": return (await backend()).pendingExchanges()
|
|
99
|
-
case "llm.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)
|
|
103
|
-
}
|
|
104
|
-
case "llm.finish": {
|
|
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)
|
|
129
|
+
const request = Frontend.decodeRequest({
|
|
130
|
+
jsonrpc: "2.0",
|
|
131
|
+
method: "ui.click",
|
|
132
|
+
params: json(required(command)),
|
|
133
|
+
})
|
|
134
|
+
if (request.method !== "ui.click")
|
|
135
|
+
throw new Error("invalid ui.click params")
|
|
136
|
+
return ui.click(request.params.target, request.params.x, request.params.y)
|
|
113
137
|
}
|
|
138
|
+
case "ui.screenshot":
|
|
139
|
+
return ui.screenshot()
|
|
140
|
+
case "ui.state":
|
|
141
|
+
return ui.state()
|
|
142
|
+
case "ui.start-record":
|
|
143
|
+
return ui.startRecord()
|
|
144
|
+
case "ui.end-record":
|
|
145
|
+
return ui.endRecord()
|
|
114
146
|
}
|
|
115
147
|
throw new Error(`unknown drive command "${command.operation}"`)
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
function required(command: DriveCommand) {
|
|
119
|
-
if (command.value === undefined)
|
|
151
|
+
if (command.value === undefined)
|
|
152
|
+
throw new Error(`${command.operation} requires a value`)
|
|
120
153
|
return command.value
|
|
121
154
|
}
|
|
122
155
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import { connect, createServer } from "node:net"
|
|
3
|
+
|
|
4
|
+
export async function listenControl(
|
|
5
|
+
path: string,
|
|
6
|
+
handlers: { readonly restart: () => Promise<void>; readonly stop: () => Promise<void> },
|
|
7
|
+
) {
|
|
8
|
+
const server = createServer((socket) => {
|
|
9
|
+
socket.setEncoding("utf8")
|
|
10
|
+
socket.once("data", (data) => {
|
|
11
|
+
const handler = handlers[String(data).trim() as keyof typeof handlers]
|
|
12
|
+
socket.destroy()
|
|
13
|
+
if (!handler) return
|
|
14
|
+
void handler().catch((error) => {
|
|
15
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
await listen(server, path)
|
|
20
|
+
return async () => {
|
|
21
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
22
|
+
await rm(path, { force: true })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function request(path: string, command: "restart" | "stop") {
|
|
27
|
+
return new Promise<void>((resolve, reject) => {
|
|
28
|
+
const socket = connect(path)
|
|
29
|
+
socket.on("connect", () => socket.end(`${command}\n`, resolve))
|
|
30
|
+
socket.on("error", () => reject(new Error("instance control socket is unavailable")))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function listen(server: ReturnType<typeof createServer>, path: string) {
|
|
35
|
+
await rm(path, { force: true })
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
server.once("error", reject)
|
|
38
|
+
server.listen(path, () => {
|
|
39
|
+
server.off("error", reject)
|
|
40
|
+
resolve()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/cli/describe.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
2
1
|
import { resolveInstance } from "./registry.js"
|
|
3
2
|
|
|
4
|
-
export async function describe(name
|
|
5
|
-
const manifest = await resolveInstance(name
|
|
3
|
+
export async function describe(name: string) {
|
|
4
|
+
const manifest = await resolveInstance(name)
|
|
6
5
|
console.log([
|
|
7
6
|
`PID: ${manifest.pid}`,
|
|
8
|
-
`
|
|
7
|
+
`Visible: ${manifest.visible}`,
|
|
9
8
|
`Artifacts: ${manifest.artifacts}`,
|
|
10
9
|
`UI: ${manifest.endpoints.ui}`,
|
|
11
10
|
`Backend: ${manifest.endpoints.backend}`,
|
|
12
|
-
`Logs: ${
|
|
11
|
+
`Logs: ${manifest.artifacts}/logs`,
|
|
13
12
|
].join("\n"))
|
|
14
13
|
}
|