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 CHANGED
@@ -1,59 +1,71 @@
1
1
  # opencode-drive
2
2
 
3
- Drive visible, headless, simulated, or real OpenCode instances.
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 detached, headless instance:**
13
+ **Start the default headless instance:**
8
14
 
9
15
  ```bash
10
16
  bunx opencode-drive start
11
17
  ```
12
18
 
13
- **Start a named instance, then address it with `--name`:**
19
+ There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
14
20
 
15
- ```bash
16
- bunx opencode-drive start --name demo
21
+ Run a local OpenCode checkout:
22
+
23
+ ```sh
24
+ bunx opencode-drive start --dev ~/projects/opencode
17
25
  ```
18
26
 
19
- **Type, submit a prompt, and take a screenshot:**
27
+ ## Send UI Commands
20
28
 
21
- ```bash
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
- **List the full command API for agents:**
31
+ ```sh
32
+ bunx opencode-drive send \
33
+ --command.ui.type '{"text":"Explain this project"}' \
34
+ --command.ui.enter
29
35
 
30
- ```bash
31
- bunx opencode-drive api
36
+ bunx opencode-drive send --command.ui.state
37
+ bunx opencode-drive send --command.ui.screenshot
32
38
  ```
33
39
 
34
- **Run OpenCode visibly in the foreground:**
40
+ Run `bunx opencode-drive api` for the full UI command contract.
35
41
 
36
- ```bash
37
- bunx opencode-drive start --visible
38
- ```
42
+ ## Scripted Mode
39
43
 
40
- **Run a local OpenCode development checkout:**
44
+ Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
41
45
 
42
- ```bash
43
- cd ~/projects/opencode
44
- bunx opencode-drive start --visible --dev .
46
+ ```sh
47
+ bunx opencode-drive start --script ./drive.ts
45
48
  ```
46
49
 
47
- **Run a custom OpenCode command after `--`:**
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
- ```bash
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
- **Stop a detached headless instance:**
54
+ The module must default-export a function receiving connected clients and the run's artifact directory:
54
55
 
55
- ```bash
56
- bunx opencode-drive stop --name demo
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
- 1. You want to develop opencode
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, run it with a development version of OpenCode with `--dev` (takes the path to the code) and `--visible` (so you can see it):
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
- 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:
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
- If you want to restart the backend, use the `/reload` command.
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
- 3. You want to share reproducible steps
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
- 4. You want to run it a billion times and assert properties
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-drive",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "description": "Drive real and simulated OpenCode instances",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -1,25 +1,45 @@
1
- import { Backend, connectBackendSimulation, connectSimulation, Frontend } from "../client/index.js"
2
- import type { DriveCommand, InstanceManifest } from "./types.js"
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": { value: true, description: "Press an arrow key using JSON params" },
9
- "ui.focus": { value: true, description: "Focus an element using JSON params" },
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": { value: false, description: "Take a screenshot and return its path" },
12
- "ui.state": { value: false, description: "Return focus, elements, and available UI actions" },
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": { 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" },
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 in commandInfo) return commandInfo[operation as keyof typeof commandInfo].value
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(manifest: InstanceManifest, commands: ReadonlyArray<DriveCommand>) {
31
- const clients: {
32
- ui?: Awaited<ReturnType<typeof connectSimulation>>
33
- backend?: Awaited<ReturnType<typeof connectBackendSimulation>>
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({ command: command.operation, result: await execute(command, ui, backend) })
41
- }
42
- return { name: manifest.name, results }
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(manifest.name, results, error)
62
+ throw new CommandBatchError(results, error)
45
63
  } finally {
46
- clients.ui?.close()
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 instance: string,
54
- readonly results: ReadonlyArray<{ readonly command: string; readonly result: unknown }>,
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: () => Promise<Awaited<ReturnType<typeof connectSimulation>>>,
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({ 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)
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({ 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)
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": return (await ui()).pressEnter()
106
+ case "ui.enter":
107
+ return ui.pressEnter()
79
108
  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)
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({ 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)
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({ 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)
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()
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) throw new Error(`${command.operation} requires a value`)
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
+ }
@@ -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?: string) {
5
- const manifest = await resolveInstance(name ?? "default")
3
+ export async function describe(name: string) {
4
+ const manifest = await resolveInstance(name)
6
5
  console.log([
7
6
  `PID: ${manifest.pid}`,
8
- `Headless: ${manifest.headless}`,
7
+ `Visible: ${manifest.visible}`,
9
8
  `Artifacts: ${manifest.artifacts}`,
10
9
  `UI: ${manifest.endpoints.ui}`,
11
10
  `Backend: ${manifest.endpoints.backend}`,
12
- `Logs: ${join(manifest.artifacts, "home", ".local", "share", "opencode", "log", "opencode*.log")}`,
11
+ `Logs: ${manifest.artifacts}/logs`,
13
12
  ].join("\n"))
14
13
  }