opencode-drive 0.1.3 → 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 +16 -96
- package/package.json +1 -1
- package/src/cli/commands.ts +4 -1
- package/src/cli/control.ts +112 -12
- package/src/cli/index.ts +76 -6
- package/src/cli/instance.ts +38 -29
- package/src/cli/list.ts +10 -0
- package/src/cli/logs.ts +9 -0
- package/src/cli/mock-backend.ts +27 -15
- package/src/cli/registry.ts +218 -23
- package/src/cli/response-generator.ts +334 -0
- package/src/cli/responses.ts +20 -0
- package/src/cli/restart.ts +1 -1
- package/src/cli/script.ts +11 -0
- package/src/cli/send.ts +4 -1
- package/src/cli/start.ts +130 -18
- package/src/cli/stop.ts +21 -4
- package/src/cli/types.ts +1 -1
- package/src/client/backend.ts +1 -1
- package/src/client/client.ts +86 -29
- package/src/client/protocol.ts +1 -0
- package/src/cli/describe.ts +0 -13
package/README.md
CHANGED
|
@@ -1,119 +1,39 @@
|
|
|
1
1
|
# opencode-drive
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This project gives your agents control over OpenCode:
|
|
4
|
+
|
|
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
|
|
4
7
|
|
|
5
8
|
## Skill
|
|
6
9
|
|
|
7
10
|
```sh
|
|
8
11
|
npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
|
|
9
12
|
```
|
|
13
|
+
## OpenCode development
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
**Start the default headless instance:**
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
bunx opencode-drive start
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
There is no "detached" version. If you want to drive an isolated app, use the scripted version below.
|
|
20
|
-
|
|
21
|
-
Run a local OpenCode checkout:
|
|
22
|
-
|
|
23
|
-
```sh
|
|
24
|
-
bunx opencode-drive start --dev ~/projects/opencode
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## Send UI Commands
|
|
28
|
-
|
|
29
|
-
`send` always connects to UI port `40900`:
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
bunx opencode-drive send \
|
|
33
|
-
--command.ui.type '{"text":"Explain this project"}' \
|
|
34
|
-
--command.ui.enter
|
|
35
|
-
|
|
36
|
-
bunx opencode-drive send --command.ui.state
|
|
37
|
-
bunx opencode-drive send --command.ui.screenshot
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Run `bunx opencode-drive api` for the full UI command contract.
|
|
41
|
-
|
|
42
|
-
## Scripted Mode
|
|
43
|
-
|
|
44
|
-
Scripted mode launches OpenCode, runs the script, and stops OpenCode when the script exits.
|
|
15
|
+
Run this:
|
|
45
16
|
|
|
46
17
|
```sh
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
|
|
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`.
|
|
51
|
-
|
|
52
|
-
Only one visible instance can be controlled this way. Headless runs exit when their script completes and do not support restart.
|
|
53
|
-
|
|
54
|
-
The module must default-export a function receiving connected clients and the run's artifact directory:
|
|
55
|
-
|
|
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
|
-
})
|
|
18
|
+
OPENCODE_DRIVE=1 bun run dev
|
|
69
19
|
```
|
|
70
20
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
There are two different modes OpenCode can run in:
|
|
74
|
-
|
|
75
|
-
* Simulated: core layers like networking are swapped out in the backend so you can control them
|
|
76
|
-
* Driven: starts websocket servers internally to expose commands to drive the app
|
|
21
|
+
If you installed the skill file, OpenCode will be able to see and interact with the running instance.
|
|
77
22
|
|
|
78
|
-
|
|
23
|
+
## Using with agents
|
|
79
24
|
|
|
80
|
-
|
|
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.
|
|
81
26
|
|
|
82
|
-
|
|
27
|
+
## UI development
|
|
83
28
|
|
|
84
|
-
|
|
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.
|
|
85
30
|
|
|
86
|
-
|
|
31
|
+
Run it in visible mode:
|
|
87
32
|
|
|
88
33
|
```sh
|
|
89
|
-
|
|
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
|
-
|
|
34
|
+
opencode-drive start --visible --dev ~/projects/opencode
|
|
97
35
|
```
|
|
98
|
-
bunx opencode-drive start --dev . --visible
|
|
99
|
-
```
|
|
100
|
-
|
|
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:
|
|
102
|
-
|
|
103
|
-
```sh
|
|
104
|
-
bunx opencode-drive restart
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
## You want opencode to develop itself
|
|
108
|
-
|
|
109
|
-
OpenCode can spawn it's own instances of OpenCode in headless mode. Use the skill in this repo to teach it about it.
|
|
110
|
-
|
|
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.
|
|
112
|
-
|
|
113
|
-
## You want to share reproducible steps
|
|
114
|
-
|
|
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)
|
|
116
36
|
|
|
117
|
-
|
|
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.
|
|
118
38
|
|
|
119
|
-
|
|
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
package/src/cli/commands.ts
CHANGED
|
@@ -47,7 +47,10 @@ export function commandNames() {
|
|
|
47
47
|
return Object.keys(commandInfo).sort()
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export async function executeCommands(
|
|
50
|
+
export async function executeCommands(
|
|
51
|
+
endpoint: string,
|
|
52
|
+
commands: ReadonlyArray<DriveCommand>,
|
|
53
|
+
) {
|
|
51
54
|
const ui = await connectSimulation({ url: endpoint })
|
|
52
55
|
const results: Array<{ readonly command: string; readonly result: unknown }> =
|
|
53
56
|
[]
|
package/src/cli/control.ts
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises"
|
|
2
2
|
import { connect, createServer } from "node:net"
|
|
3
|
+
import type {
|
|
4
|
+
ResponseConfiguration,
|
|
5
|
+
ResponseUpdate,
|
|
6
|
+
} from "./response-generator.js"
|
|
3
7
|
|
|
4
8
|
export async function listenControl(
|
|
5
9
|
path: string,
|
|
6
|
-
handlers: {
|
|
10
|
+
handlers: {
|
|
11
|
+
readonly restart: () => Promise<void>
|
|
12
|
+
readonly stop: () => Promise<void>
|
|
13
|
+
readonly responses: (
|
|
14
|
+
input: ResponseUpdate,
|
|
15
|
+
) => Promise<ResponseConfiguration>
|
|
16
|
+
},
|
|
7
17
|
) {
|
|
8
18
|
const server = createServer((socket) => {
|
|
19
|
+
let buffer = ""
|
|
9
20
|
socket.setEncoding("utf8")
|
|
10
|
-
socket.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
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
|
+
)
|
|
17
40
|
})
|
|
18
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
|
+
}
|
|
19
50
|
await listen(server, path)
|
|
20
51
|
return async () => {
|
|
21
52
|
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
@@ -23,14 +54,83 @@ export async function listenControl(
|
|
|
23
54
|
}
|
|
24
55
|
}
|
|
25
56
|
|
|
26
|
-
export function request(path: string, command: "restart" | "stop") {
|
|
27
|
-
|
|
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) => {
|
|
28
78
|
const socket = connect(path)
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
})
|
|
31
97
|
})
|
|
32
98
|
}
|
|
33
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
|
+
|
|
34
134
|
async function listen(server: ReturnType<typeof createServer>, path: string) {
|
|
35
135
|
await rm(path, { force: true })
|
|
36
136
|
await new Promise<void>((resolve, reject) => {
|
package/src/cli/index.ts
CHANGED
|
@@ -4,16 +4,32 @@ import { Effect, Option } from "effect"
|
|
|
4
4
|
import { Command, Flag } from "effect/unstable/cli"
|
|
5
5
|
import { api } from "./api.js"
|
|
6
6
|
import { extractCommands } from "./parse.js"
|
|
7
|
+
import { list } from "./list.js"
|
|
8
|
+
import { logs } from "./logs.js"
|
|
9
|
+
import { restart } from "./restart.js"
|
|
10
|
+
import { responses } from "./responses.js"
|
|
7
11
|
import { send } from "./send.js"
|
|
8
12
|
import { start } from "./start.js"
|
|
9
|
-
import {
|
|
13
|
+
import { stop } from "./stop.js"
|
|
10
14
|
import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
|
|
11
15
|
|
|
12
16
|
const extracted = extract()
|
|
17
|
+
const startName = Flag.string("name").pipe(
|
|
18
|
+
Flag.withDefault("default"),
|
|
19
|
+
Flag.withDescription("Instance name"),
|
|
20
|
+
)
|
|
21
|
+
const name = Flag.string("name").pipe(
|
|
22
|
+
Flag.optional,
|
|
23
|
+
Flag.withDescription("Instance name (inferred when exactly one is running)"),
|
|
24
|
+
)
|
|
13
25
|
|
|
14
26
|
const startCommand = Command.make(
|
|
15
27
|
"start",
|
|
16
28
|
{
|
|
29
|
+
name: startName,
|
|
30
|
+
daemon: Flag.boolean("daemon").pipe(
|
|
31
|
+
Flag.withDescription("Run as detached instance owner"),
|
|
32
|
+
),
|
|
17
33
|
script: Flag.string("script").pipe(
|
|
18
34
|
Flag.optional,
|
|
19
35
|
Flag.withDescription("JavaScript or TypeScript automation module"),
|
|
@@ -52,8 +68,16 @@ const startCommand = Command.make(
|
|
|
52
68
|
]),
|
|
53
69
|
)
|
|
54
70
|
|
|
55
|
-
const sendCommand = Command.make("send", {}, () =>
|
|
56
|
-
execute(() =>
|
|
71
|
+
const sendCommand = Command.make("send", { name }, (config) =>
|
|
72
|
+
execute(() =>
|
|
73
|
+
send(
|
|
74
|
+
toSendOptions(
|
|
75
|
+
Option.getOrUndefined(config.name),
|
|
76
|
+
extracted.commands,
|
|
77
|
+
extracted.app,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
57
81
|
).pipe(
|
|
58
82
|
Command.withDescription("Send UI commands to OpenCode on the default port"),
|
|
59
83
|
Command.withExamples([
|
|
@@ -69,18 +93,59 @@ const apiCommand = Command.make("api", {}, () => execute(api)).pipe(
|
|
|
69
93
|
Command.withDescription("Print the OpenCode drive UI protocol"),
|
|
70
94
|
)
|
|
71
95
|
|
|
72
|
-
const restartCommand = Command.make("restart", {}, () =>
|
|
96
|
+
const restartCommand = Command.make("restart", { name }, (config) =>
|
|
97
|
+
execute(() => restart(Option.getOrUndefined(config.name))),
|
|
98
|
+
).pipe(
|
|
73
99
|
Command.withDescription(
|
|
74
|
-
"Restart
|
|
100
|
+
"Restart a named OpenCode instance and rerun its script",
|
|
75
101
|
),
|
|
76
102
|
)
|
|
77
103
|
|
|
104
|
+
const stopCommand = Command.make("stop", { name }, (config) =>
|
|
105
|
+
execute(() => stop(Option.getOrUndefined(config.name))),
|
|
106
|
+
).pipe(Command.withDescription("Stop a named OpenCode instance"))
|
|
107
|
+
|
|
108
|
+
const logsCommand = Command.make("logs", { name }, (config) =>
|
|
109
|
+
execute(() => logs(Option.getOrUndefined(config.name))),
|
|
110
|
+
).pipe(Command.withDescription("List log files for a named OpenCode instance"))
|
|
111
|
+
|
|
112
|
+
const listCommand = Command.make("list", {}, () => execute(list)).pipe(
|
|
113
|
+
Command.withDescription("List active OpenCode instances"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const responsesCommand = Command.make(
|
|
117
|
+
"responses",
|
|
118
|
+
{
|
|
119
|
+
name,
|
|
120
|
+
types: Flag.string("types").pipe(
|
|
121
|
+
Flag.optional,
|
|
122
|
+
Flag.withDescription("Comma-delimited response types"),
|
|
123
|
+
),
|
|
124
|
+
tools: Flag.string("tools").pipe(
|
|
125
|
+
Flag.optional,
|
|
126
|
+
Flag.withDescription("Comma-delimited tool names, or * for all tools"),
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
(config) =>
|
|
130
|
+
execute(() =>
|
|
131
|
+
responses({
|
|
132
|
+
name: Option.getOrUndefined(config.name),
|
|
133
|
+
types: Option.getOrUndefined(config.types),
|
|
134
|
+
tools: Option.getOrUndefined(config.tools),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
).pipe(Command.withDescription("Configure simulated LLM response generation"))
|
|
138
|
+
|
|
78
139
|
const root = Command.make("opencode-drive").pipe(
|
|
79
140
|
Command.withDescription("Drive real and simulated OpenCode instances"),
|
|
80
141
|
Command.withSubcommands([
|
|
81
142
|
startCommand,
|
|
82
143
|
sendCommand,
|
|
144
|
+
listCommand,
|
|
145
|
+
responsesCommand,
|
|
146
|
+
logsCommand,
|
|
83
147
|
restartCommand,
|
|
148
|
+
stopCommand,
|
|
84
149
|
apiCommand,
|
|
85
150
|
]),
|
|
86
151
|
)
|
|
@@ -93,6 +158,8 @@ Command.runWith(root, { version: "0.1.0" })(extracted.args).pipe(
|
|
|
93
158
|
function toStartOptions(
|
|
94
159
|
config: {
|
|
95
160
|
readonly script: Option.Option<string>
|
|
161
|
+
readonly name: string
|
|
162
|
+
readonly daemon: boolean
|
|
96
163
|
readonly visible: boolean
|
|
97
164
|
readonly dev: Option.Option<string>
|
|
98
165
|
readonly state: Option.Option<string>
|
|
@@ -104,6 +171,8 @@ function toStartOptions(
|
|
|
104
171
|
throw new Error("start does not accept command flags; use send or --script")
|
|
105
172
|
const options = {
|
|
106
173
|
kind: "start" as const,
|
|
174
|
+
name: config.name,
|
|
175
|
+
daemon: config.daemon,
|
|
107
176
|
script: Option.getOrUndefined(config.script),
|
|
108
177
|
visible: config.visible,
|
|
109
178
|
dev: Option.getOrUndefined(config.dev),
|
|
@@ -116,11 +185,12 @@ function toStartOptions(
|
|
|
116
185
|
}
|
|
117
186
|
|
|
118
187
|
function toSendOptions(
|
|
188
|
+
name: string | undefined,
|
|
119
189
|
commands: ReadonlyArray<DriveCommand>,
|
|
120
190
|
app: ReadonlyArray<string>,
|
|
121
191
|
): SendOptions {
|
|
122
192
|
if (app.length > 0) throw new Error("send does not accept a command after --")
|
|
123
|
-
return { kind: "send", commands }
|
|
193
|
+
return { kind: "send", name, commands }
|
|
124
194
|
}
|
|
125
195
|
|
|
126
196
|
function execute(task: () => Promise<void>) {
|
package/src/cli/instance.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { defaultBackendPort, defaultPort } from "../client/index.js"
|
|
5
4
|
|
|
6
5
|
export interface LaunchOptions {
|
|
7
6
|
readonly name: string
|
|
@@ -13,7 +12,7 @@ export interface LaunchOptions {
|
|
|
13
12
|
readonly env?: Readonly<Record<string, string>>
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
export async function launchInstance(options: LaunchOptions
|
|
15
|
+
export async function launchInstance(options: LaunchOptions) {
|
|
17
16
|
const artifacts = resolve(
|
|
18
17
|
join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
|
|
19
18
|
)
|
|
@@ -31,7 +30,10 @@ export async function launchInstance(options: LaunchOptions = {}) {
|
|
|
31
30
|
mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
|
|
32
31
|
mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
|
|
33
32
|
])
|
|
34
|
-
await Bun.write(
|
|
33
|
+
await Bun.write(
|
|
34
|
+
join(drive, `${options.name}.json`),
|
|
35
|
+
`${JSON.stringify({ endpoints }, undefined, 2)}\n`,
|
|
36
|
+
)
|
|
35
37
|
const state = options.state
|
|
36
38
|
? resolve(options.state)
|
|
37
39
|
: join(artifacts, "state")
|
|
@@ -40,37 +42,44 @@ export async function launchInstance(options: LaunchOptions = {}) {
|
|
|
40
42
|
await Promise.all([
|
|
41
43
|
mkdir(join(state, "files", ".git"), { recursive: true }),
|
|
42
44
|
mkdir(join(state, "files", ".opencode"), { recursive: true }),
|
|
45
|
+
mkdir(join(state, "files", "src"), { recursive: true }),
|
|
43
46
|
])
|
|
44
|
-
await
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
47
|
+
await Promise.all([
|
|
48
|
+
Bun.write(
|
|
49
|
+
join(state, "files", ".opencode", "opencode.jsonc"),
|
|
50
|
+
`${JSON.stringify(
|
|
51
|
+
{
|
|
52
|
+
model: "simulation/gpt-sim-model",
|
|
53
|
+
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
54
|
+
providers: {
|
|
55
|
+
simulation: {
|
|
56
|
+
name: "Simulation",
|
|
57
|
+
package: "aisdk:@ai-sdk/openai-compatible",
|
|
58
|
+
settings: { baseURL: "https://api.openai.com/v1" },
|
|
59
|
+
request: { body: { apiKey: "sim-key" } },
|
|
60
|
+
models: {
|
|
61
|
+
"gpt-sim-model": {
|
|
62
|
+
name: "Simulated Model",
|
|
63
|
+
capabilities: {
|
|
64
|
+
tools: true,
|
|
65
|
+
input: ["text"],
|
|
66
|
+
output: ["text"],
|
|
67
|
+
},
|
|
68
|
+
limit: { context: 128000, output: 16000 },
|
|
63
69
|
},
|
|
64
|
-
limit: { context: 128000, output: 16000 },
|
|
65
70
|
},
|
|
66
71
|
},
|
|
67
72
|
},
|
|
68
73
|
},
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
+
undefined,
|
|
75
|
+
2,
|
|
76
|
+
)}\n`,
|
|
77
|
+
),
|
|
78
|
+
Bun.write(
|
|
79
|
+
join(state, "files", "src", "garden.js"),
|
|
80
|
+
'export function greet(name) {\n return `Hello, ${name}.`\n}\n',
|
|
81
|
+
),
|
|
82
|
+
])
|
|
74
83
|
}
|
|
75
84
|
const environment = cleanEnv({
|
|
76
85
|
...process.env,
|
|
@@ -301,7 +310,7 @@ async function waitForWebSocket(
|
|
|
301
310
|
const connected = await Promise.race([
|
|
302
311
|
open(url)
|
|
303
312
|
.then((socket) => {
|
|
304
|
-
socket.
|
|
313
|
+
socket.terminate()
|
|
305
314
|
return true
|
|
306
315
|
})
|
|
307
316
|
.catch(() => false),
|
package/src/cli/list.ts
ADDED
package/src/cli/logs.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { resolveInstance } from "./registry.js"
|
|
3
|
+
|
|
4
|
+
export async function logs(name?: string) {
|
|
5
|
+
const manifest = await resolveInstance(name)
|
|
6
|
+
console.log(
|
|
7
|
+
join(manifest.artifacts, "logs", "opencode", "log", "opencode*.log"),
|
|
8
|
+
)
|
|
9
|
+
}
|
package/src/cli/mock-backend.ts
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
import { connectBackendSimulation } from "../client/index.js"
|
|
2
|
+
import { generateResponse } from "./response-generator.js"
|
|
3
|
+
import type { createResponseSettings } from "./response-generator.js"
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
export async function connectMockBackend(
|
|
6
|
+
endpoint: string,
|
|
7
|
+
responses: ReturnType<typeof createResponseSettings>,
|
|
8
|
+
) {
|
|
6
9
|
const backend = await connectBackendSimulation({ url: endpoint })
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
10
|
+
await backend.attach(async (request) => {
|
|
11
|
+
const response = generateResponse(responses.current(), request)
|
|
12
|
+
for (const item of response.items) {
|
|
13
|
+
if (item.type !== "textDelta" && item.type !== "reasoningDelta") {
|
|
14
|
+
await backend.chunk(request.id, [item])
|
|
15
|
+
continue
|
|
16
|
+
}
|
|
17
|
+
for (const text of splitText(item.text)) {
|
|
18
|
+
await backend.chunk(request.id, [{ ...item, text }])
|
|
19
|
+
await Bun.sleep(45 + Math.floor(Math.random() * 35))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await backend.finish(request.id, response.finish)
|
|
18
23
|
})
|
|
19
24
|
return {
|
|
20
25
|
close() {
|
|
21
|
-
closing = true
|
|
22
26
|
backend.close()
|
|
23
27
|
},
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
export function splitText(text: string) {
|
|
32
|
+
const words = text.match(/\S+\s*/g) ?? [text]
|
|
33
|
+
return Array.from(
|
|
34
|
+
{ length: Math.ceil(words.length / 3) },
|
|
35
|
+
(_, index) => words.slice(index * 3, index * 3 + 3).join(""),
|
|
36
|
+
)
|
|
37
|
+
}
|