opencode-drive 0.1.2 → 0.1.4

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,100 +1,39 @@
1
1
  # opencode-drive
2
2
 
3
- Drive visible, headless, simulated, or real OpenCode instances.
3
+ This project gives your agents control over OpenCode:
4
4
 
5
- # Usage
5
+ * Run it during development and let your agents see and poke at the running instance
6
+ * Allow your agents to run it in headless mode and drive it to test things
6
7
 
7
- **Start the default detached, headless instance:**
8
+ ## Skill
8
9
 
9
- ```bash
10
- bunx opencode-drive start
11
- ```
12
-
13
- **Start a named instance, then address it with `--name`:**
14
-
15
- ```bash
16
- bunx opencode-drive start --name demo
17
- ```
18
-
19
- **Type, submit a prompt, and take a screenshot:**
20
-
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
- ```
27
-
28
- **List the full command API for agents:**
29
-
30
- ```bash
31
- bunx opencode-drive api
32
- ```
33
-
34
- **Run OpenCode visibly in the foreground:**
35
-
36
- ```bash
37
- bunx opencode-drive start --visible
38
- ```
39
-
40
- **Run a local OpenCode development checkout:**
41
-
42
- ```bash
43
- cd ~/projects/opencode
44
- bunx opencode-drive start --visible --dev .
45
- ```
46
-
47
- **Run a custom OpenCode command after `--`:**
48
-
49
- ```bash
50
- bunx opencode-drive start --name demo -- opencode2 --standalone
10
+ ```sh
11
+ npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
51
12
  ```
13
+ ## OpenCode development
52
14
 
53
- **Stop a detached headless instance:**
15
+ Run this:
54
16
 
55
- ```bash
56
- bunx opencode-drive stop --name demo
17
+ ```sh
18
+ OPENCODE_DRIVE=1 bun run dev
57
19
  ```
58
20
 
59
- # Use cases
60
-
61
- There are two different modes OpenCode can run in:
62
-
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
65
-
66
- You can choose one or the other, or both! This allows the following use cases:
67
-
68
- 1. You want to develop opencode
21
+ If you installed the skill file, OpenCode will be able to see and interact with the running instance.
69
22
 
70
- You are running a development version of OpenCode to work on it. You have another OpenCode instance making changes to the app.
23
+ ## Using with agents
71
24
 
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.
25
+ Install the skill file above and ask the agent to test various flows with the app. You also ask it to make a recording and it will generate a video file for you to watch anything it did.
73
26
 
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):
27
+ ## UI development
75
28
 
76
- ```sh
77
- bunx opencode-drive start --dev . --visible
78
- ```
29
+ If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
79
30
 
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:
31
+ Run it in visible mode:
81
32
 
82
33
  ```sh
83
- bunx opencode-drive restart
34
+ opencode-drive start --visible --dev ~/projects/opencode
84
35
  ```
85
36
 
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
37
+ While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
99
38
 
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.
39
+ View the [skills file](https://github.com/jlongster/opencode-drive/blob/main/skills/opencode-drive/SKILL.md) for more details about the CLI.
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.4",
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,33 @@ 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(
51
+ endpoint: string,
52
+ commands: ReadonlyArray<DriveCommand>,
53
+ ) {
54
+ const ui = await connectSimulation({ url: endpoint })
55
+ const results: Array<{ readonly command: string; readonly result: unknown }> =
56
+ []
38
57
  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 }
58
+ for (const command of commands)
59
+ results.push({
60
+ command: command.operation,
61
+ result: await execute(command, ui),
62
+ })
63
+ return { results }
43
64
  } catch (error) {
44
- throw new CommandBatchError(manifest.name, results, error)
65
+ throw new CommandBatchError(results, error)
45
66
  } finally {
46
- clients.ui?.close()
47
- clients.backend?.close()
67
+ ui.close()
48
68
  }
49
69
  }
50
70
 
51
71
  export class CommandBatchError extends Error {
52
72
  constructor(
53
- readonly instance: string,
54
- readonly results: ReadonlyArray<{ readonly command: string; readonly result: unknown }>,
73
+ readonly results: ReadonlyArray<{
74
+ readonly command: string
75
+ readonly result: unknown
76
+ }>,
55
77
  readonly reason: unknown,
56
78
  ) {
57
79
  super(reason instanceof Error ? reason.message : String(reason))
@@ -61,62 +83,76 @@ export class CommandBatchError extends Error {
61
83
 
62
84
  async function execute(
63
85
  command: DriveCommand,
64
- ui: () => Promise<Awaited<ReturnType<typeof connectSimulation>>>,
65
- backend: () => Promise<Awaited<ReturnType<typeof connectBackendSimulation>>>,
86
+ ui: Awaited<ReturnType<typeof connectSimulation>>,
66
87
  ) {
67
88
  switch (command.operation) {
68
89
  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)
90
+ const request = Frontend.decodeRequest({
91
+ jsonrpc: "2.0",
92
+ method: "ui.type",
93
+ params: json(required(command)),
94
+ })
95
+ if (request.method !== "ui.type")
96
+ throw new Error("invalid ui.type params")
97
+ return ui.typeText(request.params.text)
72
98
  }
73
99
  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)
100
+ const request = Frontend.decodeRequest({
101
+ jsonrpc: "2.0",
102
+ method: "ui.press",
103
+ params: json(required(command)),
104
+ })
105
+ if (request.method !== "ui.press")
106
+ throw new Error("invalid ui.press params")
107
+ return ui.pressKey(request.params.key, request.params.modifiers)
77
108
  }
78
- case "ui.enter": return (await ui()).pressEnter()
109
+ case "ui.enter":
110
+ return ui.pressEnter()
79
111
  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)
112
+ const request = Frontend.decodeRequest({
113
+ jsonrpc: "2.0",
114
+ method: "ui.arrow",
115
+ params: json(required(command)),
116
+ })
117
+ if (request.method !== "ui.arrow")
118
+ throw new Error("invalid ui.arrow params")
119
+ return ui.pressArrow(request.params.direction)
83
120
  }
84
121
  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)
122
+ const request = Frontend.decodeRequest({
123
+ jsonrpc: "2.0",
124
+ method: "ui.focus",
125
+ params: json(required(command)),
126
+ })
127
+ if (request.method !== "ui.focus")
128
+ throw new Error("invalid ui.focus params")
129
+ return ui.focus(request.params.target)
88
130
  }
89
131
  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)
132
+ const request = Frontend.decodeRequest({
133
+ jsonrpc: "2.0",
134
+ method: "ui.click",
135
+ params: json(required(command)),
136
+ })
137
+ if (request.method !== "ui.click")
138
+ throw new Error("invalid ui.click params")
139
+ return ui.click(request.params.target, request.params.x, request.params.y)
113
140
  }
141
+ case "ui.screenshot":
142
+ return ui.screenshot()
143
+ case "ui.state":
144
+ return ui.state()
145
+ case "ui.start-record":
146
+ return ui.startRecord()
147
+ case "ui.end-record":
148
+ return ui.endRecord()
114
149
  }
115
150
  throw new Error(`unknown drive command "${command.operation}"`)
116
151
  }
117
152
 
118
153
  function required(command: DriveCommand) {
119
- if (command.value === undefined) throw new Error(`${command.operation} requires a value`)
154
+ if (command.value === undefined)
155
+ throw new Error(`${command.operation} requires a value`)
120
156
  return command.value
121
157
  }
122
158
 
@@ -0,0 +1,143 @@
1
+ import { rm } from "node:fs/promises"
2
+ import { connect, createServer } from "node:net"
3
+ import type {
4
+ ResponseConfiguration,
5
+ ResponseUpdate,
6
+ } from "./response-generator.js"
7
+
8
+ export async function listenControl(
9
+ path: string,
10
+ handlers: {
11
+ readonly restart: () => Promise<void>
12
+ readonly stop: () => Promise<void>
13
+ readonly responses: (
14
+ input: ResponseUpdate,
15
+ ) => Promise<ResponseConfiguration>
16
+ },
17
+ ) {
18
+ const server = createServer((socket) => {
19
+ let buffer = ""
20
+ socket.setEncoding("utf8")
21
+ socket.on("data", (data) => {
22
+ buffer += data
23
+ if (buffer.length > 64 * 1024) {
24
+ socket.removeAllListeners("data")
25
+ socket.end("error: control request exceeds 64 KiB\n")
26
+ return
27
+ }
28
+ if (!buffer.includes("\n")) return
29
+ socket.removeAllListeners("data")
30
+ void handle(buffer.slice(0, buffer.indexOf("\n"))).then(
31
+ (result) =>
32
+ socket.end(
33
+ `success${result === undefined ? "" : ` ${JSON.stringify(result)}`}\n`,
34
+ ),
35
+ (error) =>
36
+ socket.end(
37
+ `error: ${error instanceof Error ? error.message : String(error)}\n`,
38
+ ),
39
+ )
40
+ })
41
+ })
42
+ const handle = async (input: string) => {
43
+ if (input === "restart") return handlers.restart()
44
+ if (input === "stop") return handlers.stop()
45
+ if (input === "responses") return handlers.responses({})
46
+ if (input.startsWith("responses "))
47
+ return handlers.responses(parseResponseUpdate(input.slice("responses ".length)))
48
+ throw new Error("unknown control command")
49
+ }
50
+ await listen(server, path)
51
+ return async () => {
52
+ await new Promise<void>((resolve) => server.close(() => resolve()))
53
+ await rm(path, { force: true })
54
+ }
55
+ }
56
+
57
+ export async function request(path: string, command: "restart" | "stop") {
58
+ const response = await send(path, command)
59
+ if (response !== "success") throw responseError(response)
60
+ }
61
+
62
+ export async function requestResponses(path: string, input: ResponseUpdate) {
63
+ const response = await send(
64
+ path,
65
+ Object.keys(input).length === 0
66
+ ? "responses"
67
+ : `responses ${JSON.stringify(input)}`,
68
+ )
69
+ if (!response.startsWith("success ")) throw responseError(response)
70
+ const value: unknown = JSON.parse(response.slice("success ".length))
71
+ if (!isResponseConfiguration(value))
72
+ throw new Error("instance returned an invalid response configuration")
73
+ return value
74
+ }
75
+
76
+ function send(path: string, command: string) {
77
+ return new Promise<string>((resolve, reject) => {
78
+ const socket = connect(path)
79
+ let response = ""
80
+ const timer = setTimeout(() => {
81
+ socket.destroy()
82
+ reject(new Error("instance control request timed out"))
83
+ }, 10_000)
84
+ socket.setEncoding("utf8")
85
+ socket.on("connect", () => socket.write(`${command}\n`))
86
+ socket.on("data", (data) => {
87
+ response += data
88
+ })
89
+ socket.on("end", () => {
90
+ clearTimeout(timer)
91
+ resolve(response.trim())
92
+ })
93
+ socket.on("error", () => {
94
+ clearTimeout(timer)
95
+ reject(new Error("instance control socket is unavailable"))
96
+ })
97
+ })
98
+ }
99
+
100
+ function parseResponseUpdate(input: string): ResponseUpdate {
101
+ const value: unknown = JSON.parse(input)
102
+ if (typeof value !== "object" || value === null || Array.isArray(value))
103
+ throw new Error("invalid responses configuration")
104
+ const types = "types" in value ? stringArray(value.types) : undefined
105
+ const tools = "tools" in value ? stringArray(value.tools) : undefined
106
+ return {
107
+ ...(types === undefined ? {} : { types }),
108
+ ...(tools === undefined ? {} : { tools }),
109
+ }
110
+ }
111
+
112
+ function stringArray(value: unknown) {
113
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string"))
114
+ throw new Error("response types and tools must be string arrays")
115
+ return value
116
+ }
117
+
118
+ function isResponseConfiguration(
119
+ value: unknown,
120
+ ): value is ResponseConfiguration {
121
+ if (typeof value !== "object" || value === null) return false
122
+ if (!("types" in value) || !stringArrayValue(value.types)) return false
123
+ return "tools" in value && stringArrayValue(value.tools)
124
+ }
125
+
126
+ function stringArrayValue(value: unknown) {
127
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
128
+ }
129
+
130
+ function responseError(response: string) {
131
+ return new Error(response.replace(/^error:\s*/, "") || "empty control response")
132
+ }
133
+
134
+ async function listen(server: ReturnType<typeof createServer>, path: string) {
135
+ await rm(path, { force: true })
136
+ await new Promise<void>((resolve, reject) => {
137
+ server.once("error", reject)
138
+ server.listen(path, () => {
139
+ server.off("error", reject)
140
+ resolve()
141
+ })
142
+ })
143
+ }