opencode-drive 0.2.0 → 0.2.1
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 +15 -3
- package/package.json +1 -1
- package/src/cli/index.ts +24 -31
- package/src/cli/init.ts +7 -0
- package/src/cli/instance.ts +45 -68
- package/src/cli/list.ts +3 -5
- package/src/cli/registry.ts +77 -47
- package/src/cli/start.ts +21 -29
package/README.md
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
This project gives your agents control over OpenCode:
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
7
7
|
|
|
8
8
|
## Skill
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
11
|
npx skills add jlongster/opencode-drive --agent opencode --skill opencode-drive
|
|
12
12
|
```
|
|
13
|
+
|
|
13
14
|
## OpenCode development
|
|
14
15
|
|
|
15
16
|
Run this:
|
|
@@ -33,9 +34,20 @@ If you are doing UI development in OpenCode, you might want to run it in a simul
|
|
|
33
34
|
Run it in visible mode:
|
|
34
35
|
|
|
35
36
|
```sh
|
|
36
|
-
opencode-drive start --visible --dev ~/projects/opencode
|
|
37
|
+
opencode-drive start --name demo --visible --dev ~/projects/opencode
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Initialize first when you need to customize the isolated environment before OpenCode starts:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
artifacts=$(opencode-drive init --name demo)
|
|
44
|
+
cp -R ./fixtures/home/. "$artifacts/"
|
|
45
|
+
cp -R ./fixtures/project/. "$artifacts/files/"
|
|
46
|
+
opencode-drive start --name demo --visible --dev ~/projects/opencode
|
|
37
47
|
```
|
|
38
48
|
|
|
49
|
+
`start` reuses the prepared artifacts for that name. If `init` was not run, `start` initializes them automatically.
|
|
50
|
+
|
|
39
51
|
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.
|
|
40
52
|
|
|
41
53
|
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/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Effect, Option } from "effect"
|
|
|
4
4
|
import { Command, Flag } from "effect/unstable/cli"
|
|
5
5
|
import packageJson from "../../package.json" with { type: "json" }
|
|
6
6
|
import { extractCommands } from "./parse.js"
|
|
7
|
+
import { init } from "./init.js"
|
|
7
8
|
import { list } from "./list.js"
|
|
8
9
|
import { logs } from "./logs.js"
|
|
9
10
|
import { restart } from "./restart.js"
|
|
@@ -15,7 +16,6 @@ import type { DriveCommand, SendOptions, StartOptions } from "./types.js"
|
|
|
15
16
|
|
|
16
17
|
const extracted = extract()
|
|
17
18
|
const startName = Flag.string("name").pipe(
|
|
18
|
-
Flag.withDefault("default"),
|
|
19
19
|
Flag.withDescription("Instance name"),
|
|
20
20
|
)
|
|
21
21
|
const name = Flag.string("name").pipe(
|
|
@@ -23,6 +23,18 @@ const name = Flag.string("name").pipe(
|
|
|
23
23
|
Flag.withDescription("Instance name (inferred when exactly one is running)"),
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
+
const initCommand = Command.make("init", { name: startName }, (config) =>
|
|
27
|
+
execute(() => init(config.name)),
|
|
28
|
+
).pipe(
|
|
29
|
+
Command.withDescription("Initialize an instance without launching OpenCode"),
|
|
30
|
+
Command.withExamples([
|
|
31
|
+
{
|
|
32
|
+
command: "opencode-drive init --name demo",
|
|
33
|
+
description: "Create an instance and print its artifact directory",
|
|
34
|
+
},
|
|
35
|
+
]),
|
|
36
|
+
)
|
|
37
|
+
|
|
26
38
|
const startCommand = Command.make(
|
|
27
39
|
"start",
|
|
28
40
|
{
|
|
@@ -35,9 +47,7 @@ const startCommand = Command.make(
|
|
|
35
47
|
Flag.optional,
|
|
36
48
|
Flag.withDescription("JavaScript or TypeScript automation module"),
|
|
37
49
|
),
|
|
38
|
-
visible: Flag.boolean("visible").pipe(
|
|
39
|
-
Flag.withDescription("Show OpenCode in the terminal"),
|
|
40
|
-
),
|
|
50
|
+
visible: Flag.boolean("visible").pipe(Flag.withDescription("Show OpenCode in the terminal")),
|
|
41
51
|
record: Flag.boolean("record").pipe(
|
|
42
52
|
Flag.withDescription("Record the complete headless session and export it on stop"),
|
|
43
53
|
),
|
|
@@ -46,23 +56,20 @@ const startCommand = Command.make(
|
|
|
46
56
|
Flag.withDescription("Path to an OpenCode development checkout"),
|
|
47
57
|
),
|
|
48
58
|
},
|
|
49
|
-
(config) =>
|
|
50
|
-
execute(() =>
|
|
51
|
-
start(toStartOptions(config, extracted.commands, extracted.app)),
|
|
52
|
-
),
|
|
59
|
+
(config) => execute(() => start(toStartOptions(config, extracted.commands, extracted.app))),
|
|
53
60
|
).pipe(
|
|
54
61
|
Command.withDescription("Launch a local simulated OpenCode instance"),
|
|
55
62
|
Command.withExamples([
|
|
56
63
|
{
|
|
57
|
-
command: "opencode-drive start",
|
|
64
|
+
command: "opencode-drive start --name demo",
|
|
58
65
|
description: "Launch headless OpenCode on the default ports",
|
|
59
66
|
},
|
|
60
67
|
{
|
|
61
|
-
command: "opencode-drive start --visible",
|
|
68
|
+
command: "opencode-drive start --name demo --visible",
|
|
62
69
|
description: "Launch visible OpenCode on the default ports",
|
|
63
70
|
},
|
|
64
71
|
{
|
|
65
|
-
command: "opencode-drive start --script ./drive.ts",
|
|
72
|
+
command: "opencode-drive start --name demo --script ./drive.ts",
|
|
66
73
|
description: "Launch headless OpenCode and run a script",
|
|
67
74
|
},
|
|
68
75
|
]),
|
|
@@ -70,20 +77,13 @@ const startCommand = Command.make(
|
|
|
70
77
|
|
|
71
78
|
const sendCommand = Command.make("send", { name }, (config) =>
|
|
72
79
|
execute(() =>
|
|
73
|
-
send(
|
|
74
|
-
toSendOptions(
|
|
75
|
-
Option.getOrUndefined(config.name),
|
|
76
|
-
extracted.commands,
|
|
77
|
-
extracted.app,
|
|
78
|
-
),
|
|
79
|
-
),
|
|
80
|
+
send(toSendOptions(Option.getOrUndefined(config.name), extracted.commands, extracted.app)),
|
|
80
81
|
),
|
|
81
82
|
).pipe(
|
|
82
83
|
Command.withDescription("Send UI commands to OpenCode on the default port"),
|
|
83
84
|
Command.withExamples([
|
|
84
85
|
{
|
|
85
|
-
command:
|
|
86
|
-
'opencode-drive send --command.ui.type \'{"text":"hello"}\' --command.ui.state',
|
|
86
|
+
command: 'opencode-drive send --command.ui.type \'{"text":"hello"}\' --command.ui.state',
|
|
87
87
|
description: "Execute an ordered UI command batch",
|
|
88
88
|
},
|
|
89
89
|
]),
|
|
@@ -101,11 +101,7 @@ const screenshotCommand = Command.make("screenshot", { name }, (config) =>
|
|
|
101
101
|
|
|
102
102
|
const restartCommand = Command.make("restart", { name }, (config) =>
|
|
103
103
|
execute(() => restart(Option.getOrUndefined(config.name))),
|
|
104
|
-
).pipe(
|
|
105
|
-
Command.withDescription(
|
|
106
|
-
"Restart a named OpenCode instance and rerun its script",
|
|
107
|
-
),
|
|
108
|
-
)
|
|
104
|
+
).pipe(Command.withDescription("Restart a named OpenCode instance and rerun its script"))
|
|
109
105
|
|
|
110
106
|
const stopCommand = Command.make("stop", { name }, (config) =>
|
|
111
107
|
execute(() => stop(Option.getOrUndefined(config.name))),
|
|
@@ -145,6 +141,7 @@ const responsesCommand = Command.make(
|
|
|
145
141
|
const root = Command.make("opencode-drive").pipe(
|
|
146
142
|
Command.withDescription("Drive real and simulated OpenCode instances"),
|
|
147
143
|
Command.withSubcommands([
|
|
144
|
+
initCommand,
|
|
148
145
|
startCommand,
|
|
149
146
|
sendCommand,
|
|
150
147
|
screenshotCommand,
|
|
@@ -203,9 +200,7 @@ function execute(task: () => Promise<void>) {
|
|
|
203
200
|
return Effect.tryPromise({ try: task, catch: (error) => error }).pipe(
|
|
204
201
|
Effect.catch((error) =>
|
|
205
202
|
Effect.sync(() => {
|
|
206
|
-
console.error(
|
|
207
|
-
`error: ${error instanceof Error ? error.message : String(error)}`,
|
|
208
|
-
)
|
|
203
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`)
|
|
209
204
|
process.exitCode = 1
|
|
210
205
|
}),
|
|
211
206
|
),
|
|
@@ -216,9 +211,7 @@ function extract() {
|
|
|
216
211
|
try {
|
|
217
212
|
return extractCommands(process.argv.slice(2))
|
|
218
213
|
} catch (error) {
|
|
219
|
-
console.error(
|
|
220
|
-
`opencode-drive: ${error instanceof Error ? error.message : String(error)}`,
|
|
221
|
-
)
|
|
214
|
+
console.error(`opencode-drive: ${error instanceof Error ? error.message : String(error)}`)
|
|
222
215
|
return process.exit(1)
|
|
223
216
|
}
|
|
224
217
|
}
|
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { initializeInstance } from "./instance.js"
|
|
2
|
+
import { initializeManifest } from "./registry.js"
|
|
3
|
+
|
|
4
|
+
export async function init(name: string) {
|
|
5
|
+
const manifest = await initializeManifest(name, process.cwd(), initializeInstance)
|
|
6
|
+
console.log(manifest.artifacts)
|
|
7
|
+
}
|
package/src/cli/instance.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { ensureMediaDirectory } from "./media.js"
|
|
|
5
5
|
import type { DriveScriptSetup } from "./script.js"
|
|
6
6
|
|
|
7
7
|
export interface LaunchOptions {
|
|
8
|
+
readonly artifacts: string
|
|
8
9
|
readonly name: string
|
|
9
10
|
readonly command?: ReadonlyArray<string>
|
|
10
11
|
readonly dev?: string
|
|
@@ -15,17 +16,12 @@ export interface LaunchOptions {
|
|
|
15
16
|
readonly setup?: DriveScriptSetup
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export async function
|
|
19
|
+
export async function initializeInstance() {
|
|
19
20
|
const artifacts = resolve(
|
|
20
21
|
join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
|
|
21
22
|
)
|
|
22
23
|
const logs = join(artifacts, "logs")
|
|
23
|
-
const endpoints = {
|
|
24
|
-
ui: `ws://127.0.0.1:${await freePort()}`,
|
|
25
|
-
backend: `ws://127.0.0.1:${await freePort()}`,
|
|
26
|
-
}
|
|
27
24
|
const drive = join(artifacts, "drive")
|
|
28
|
-
const media = await ensureMediaDirectory()
|
|
29
25
|
await Promise.all([
|
|
30
26
|
mkdir(logs, { recursive: true }),
|
|
31
27
|
mkdir(drive, { recursive: true }),
|
|
@@ -34,20 +30,6 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
34
30
|
mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
|
|
35
31
|
mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
|
|
36
32
|
])
|
|
37
|
-
let recording = options.record ? recordingPaths(media) : undefined
|
|
38
|
-
const writeDriveManifest = () =>
|
|
39
|
-
Bun.write(
|
|
40
|
-
join(drive, `${options.name}.json`),
|
|
41
|
-
`${JSON.stringify(
|
|
42
|
-
{
|
|
43
|
-
endpoints,
|
|
44
|
-
...(recording ? { recording: { timeline: recording.timeline } } : {}),
|
|
45
|
-
},
|
|
46
|
-
undefined,
|
|
47
|
-
2,
|
|
48
|
-
)}\n`,
|
|
49
|
-
)
|
|
50
|
-
await writeDriveManifest()
|
|
51
33
|
const files = join(artifacts, "files")
|
|
52
34
|
await Promise.all([
|
|
53
35
|
mkdir(join(files, ".git"), { recursive: true }),
|
|
@@ -87,9 +69,36 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
87
69
|
),
|
|
88
70
|
Bun.write(
|
|
89
71
|
join(files, "src", "garden.js"),
|
|
90
|
-
|
|
72
|
+
"export function greet(name) {\n return `Hello, ${name}.`\n}\n",
|
|
91
73
|
),
|
|
92
74
|
])
|
|
75
|
+
return artifacts
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function launchInstance(options: LaunchOptions) {
|
|
79
|
+
const artifacts = resolve(options.artifacts)
|
|
80
|
+
const logs = join(artifacts, "logs")
|
|
81
|
+
const drive = join(artifacts, "drive")
|
|
82
|
+
const endpoints = {
|
|
83
|
+
ui: `ws://127.0.0.1:${await freePort()}`,
|
|
84
|
+
backend: `ws://127.0.0.1:${await freePort()}`,
|
|
85
|
+
}
|
|
86
|
+
const media = await ensureMediaDirectory()
|
|
87
|
+
const files = join(artifacts, "files")
|
|
88
|
+
let recording = options.record ? recordingPaths(media) : undefined
|
|
89
|
+
const writeDriveManifest = () =>
|
|
90
|
+
Bun.write(
|
|
91
|
+
join(drive, `${options.name}.json`),
|
|
92
|
+
`${JSON.stringify(
|
|
93
|
+
{
|
|
94
|
+
endpoints,
|
|
95
|
+
...(recording ? { recording: { timeline: recording.timeline } } : {}),
|
|
96
|
+
},
|
|
97
|
+
undefined,
|
|
98
|
+
2,
|
|
99
|
+
)}\n`,
|
|
100
|
+
)
|
|
101
|
+
await writeDriveManifest()
|
|
93
102
|
await options.setup?.({ directory: files })
|
|
94
103
|
const environment = cleanEnv({
|
|
95
104
|
...process.env,
|
|
@@ -101,9 +110,7 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
101
110
|
OPENCODE_DRIVE_MEDIA_DIR: media,
|
|
102
111
|
OPENCODE_CONFIG_DIR: join(files, ".opencode"),
|
|
103
112
|
OPENCODE_DB: ":memory:",
|
|
104
|
-
OPENCODE_LOG_LEVEL: !options.visible
|
|
105
|
-
? "DEBUG"
|
|
106
|
-
: process.env.OPENCODE_LOG_LEVEL,
|
|
113
|
+
OPENCODE_LOG_LEVEL: !options.visible ? "DEBUG" : process.env.OPENCODE_LOG_LEVEL,
|
|
107
114
|
OPENCODE_TEST_HOME: artifacts,
|
|
108
115
|
XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
|
|
109
116
|
XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
|
|
@@ -120,12 +127,8 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
120
127
|
cwd: files,
|
|
121
128
|
env: environment,
|
|
122
129
|
stdin: options.visible ? "inherit" : "ignore",
|
|
123
|
-
stdout: !options.visible
|
|
124
|
-
|
|
125
|
-
: "inherit",
|
|
126
|
-
stderr: !options.visible
|
|
127
|
-
? Bun.file(join(logs, "opencode.stderr.log"))
|
|
128
|
-
: "inherit",
|
|
130
|
+
stdout: !options.visible ? Bun.file(join(logs, "opencode.stdout.log")) : "inherit",
|
|
131
|
+
stderr: !options.visible ? Bun.file(join(logs, "opencode.stderr.log")) : "inherit",
|
|
129
132
|
})
|
|
130
133
|
let child = spawn()
|
|
131
134
|
let stopping: Promise<void> | undefined
|
|
@@ -140,17 +143,10 @@ export async function launchInstance(options: LaunchOptions) {
|
|
|
140
143
|
get child() {
|
|
141
144
|
return child
|
|
142
145
|
},
|
|
143
|
-
async waitForDrive(
|
|
144
|
-
requirement: "ui" | "backend" | "both" = "both",
|
|
145
|
-
timeout = 60_000,
|
|
146
|
-
) {
|
|
146
|
+
async waitForDrive(requirement: "ui" | "backend" | "both" = "both", timeout = 60_000) {
|
|
147
147
|
const urls =
|
|
148
|
-
requirement === "both"
|
|
149
|
-
|
|
150
|
-
: [endpoints[requirement]]
|
|
151
|
-
await Promise.all(
|
|
152
|
-
urls.map((url) => waitForWebSocket(url, child.exited, timeout)),
|
|
153
|
-
)
|
|
148
|
+
requirement === "both" ? [endpoints.ui, endpoints.backend] : [endpoints[requirement]]
|
|
149
|
+
await Promise.all(urls.map((url) => waitForWebSocket(url, child.exited, timeout)))
|
|
154
150
|
},
|
|
155
151
|
async restart() {
|
|
156
152
|
if (restarting) return restarting
|
|
@@ -248,14 +244,8 @@ async function prepareDev(cwd: string, directory: string) {
|
|
|
248
244
|
stderr: "ignore",
|
|
249
245
|
})
|
|
250
246
|
const status = await install.exited
|
|
251
|
-
if (status !== 0)
|
|
252
|
-
|
|
253
|
-
return [
|
|
254
|
-
process.execPath,
|
|
255
|
-
"--conditions=browser",
|
|
256
|
-
"--preload=@opentui/solid/preload",
|
|
257
|
-
entrypoint,
|
|
258
|
-
]
|
|
247
|
+
if (status !== 0) throw new Error(`bun install failed in ${cwd} with status ${status}`)
|
|
248
|
+
return [process.execPath, "--conditions=browser", "--preload=@opentui/solid/preload", entrypoint]
|
|
259
249
|
}
|
|
260
250
|
|
|
261
251
|
async function freePort() {
|
|
@@ -298,10 +288,7 @@ async function stopService(state: string) {
|
|
|
298
288
|
|
|
299
289
|
function isServiceInfo(value: unknown): value is { readonly pid: number } {
|
|
300
290
|
return (
|
|
301
|
-
typeof value === "object" &&
|
|
302
|
-
value !== null &&
|
|
303
|
-
"pid" in value &&
|
|
304
|
-
typeof value.pid === "number"
|
|
291
|
+
typeof value === "object" && value !== null && "pid" in value && typeof value.pid === "number"
|
|
305
292
|
)
|
|
306
293
|
}
|
|
307
294
|
|
|
@@ -323,11 +310,7 @@ function isPackageInfo(value: unknown): value is { readonly version: string } {
|
|
|
323
310
|
)
|
|
324
311
|
}
|
|
325
312
|
|
|
326
|
-
async function waitForWebSocket(
|
|
327
|
-
url: string,
|
|
328
|
-
exited: Promise<number>,
|
|
329
|
-
timeout: number,
|
|
330
|
-
) {
|
|
313
|
+
async function waitForWebSocket(url: string, exited: Promise<number>, timeout: number) {
|
|
331
314
|
const deadline = Date.now() + timeout
|
|
332
315
|
while (Date.now() < deadline) {
|
|
333
316
|
const connected = await Promise.race([
|
|
@@ -338,9 +321,7 @@ async function waitForWebSocket(
|
|
|
338
321
|
})
|
|
339
322
|
.catch(() => false),
|
|
340
323
|
exited.then((code) => {
|
|
341
|
-
throw new Error(
|
|
342
|
-
`OpenCode exited with status ${code} before ${url} became ready`,
|
|
343
|
-
)
|
|
324
|
+
throw new Error(`OpenCode exited with status ${code} before ${url} became ready`)
|
|
344
325
|
}),
|
|
345
326
|
])
|
|
346
327
|
if (connected) return
|
|
@@ -355,18 +336,14 @@ function open(url: string) {
|
|
|
355
336
|
socket.addEventListener("open", () => resolveSocket(socket), {
|
|
356
337
|
once: true,
|
|
357
338
|
})
|
|
358
|
-
socket.addEventListener(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{ once: true },
|
|
362
|
-
)
|
|
339
|
+
socket.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), {
|
|
340
|
+
once: true,
|
|
341
|
+
})
|
|
363
342
|
})
|
|
364
343
|
}
|
|
365
344
|
|
|
366
345
|
function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
|
|
367
346
|
return Object.fromEntries(
|
|
368
|
-
Object.entries(env).filter(
|
|
369
|
-
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
370
|
-
),
|
|
347
|
+
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
371
348
|
)
|
|
372
349
|
}
|
package/src/cli/list.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { listManifests, manifestPath } from "./registry.js"
|
|
2
2
|
|
|
3
3
|
export async function list() {
|
|
4
|
-
const instances = await
|
|
4
|
+
const instances = await listManifests()
|
|
5
5
|
console.log(
|
|
6
|
-
instances
|
|
7
|
-
.map((instance) => `${instance.name}: ${manifestPath(instance.name)}`)
|
|
8
|
-
.join("\n"),
|
|
6
|
+
instances.map((instance) => `${instance.name}: ${manifestPath(instance.name)}`).join("\n"),
|
|
9
7
|
)
|
|
10
8
|
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -15,6 +15,17 @@ export interface InstanceManifest {
|
|
|
15
15
|
readonly control: string
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export interface InitializedManifest {
|
|
19
|
+
readonly version: 1
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly createdAt: string
|
|
22
|
+
readonly cwd: string
|
|
23
|
+
readonly artifacts: string
|
|
24
|
+
readonly status: "initialized"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type Manifest = InstanceManifest | InitializedManifest
|
|
28
|
+
|
|
18
29
|
export function registryDirectory() {
|
|
19
30
|
return (
|
|
20
31
|
process.env.DRIVE_REGISTRY_DIR ??
|
|
@@ -34,10 +45,38 @@ export function controlPath(name: string) {
|
|
|
34
45
|
return join(registryDirectory(), `${validateName(name)}.sock`)
|
|
35
46
|
}
|
|
36
47
|
|
|
48
|
+
export async function initializeManifest(name: string, cwd: string, create: () => Promise<string>) {
|
|
49
|
+
let initialized: InitializedManifest | undefined
|
|
50
|
+
await withLock(name, false, async () => {
|
|
51
|
+
const existing = await read(manifestPath(name))
|
|
52
|
+
if (existing?.status === "initialized") {
|
|
53
|
+
initialized = existing
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (existing && alive(existing.pid))
|
|
57
|
+
throw new Error(`drive instance "${name}" is already running`)
|
|
58
|
+
initialized = {
|
|
59
|
+
version: 1,
|
|
60
|
+
name,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
cwd,
|
|
63
|
+
artifacts: await create(),
|
|
64
|
+
status: "initialized",
|
|
65
|
+
}
|
|
66
|
+
await Promise.all([
|
|
67
|
+
rm(manifestPath(name), { force: true }),
|
|
68
|
+
rm(controlPath(name), { force: true }),
|
|
69
|
+
])
|
|
70
|
+
await write(initialized)
|
|
71
|
+
})
|
|
72
|
+
if (!initialized) throw new Error(`failed to initialize drive instance "${name}"`)
|
|
73
|
+
return initialized
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
export async function register(manifest: InstanceManifest) {
|
|
38
77
|
await withLock(manifest.name, false, async () => {
|
|
39
78
|
const existing = await read(manifestPath(manifest.name))
|
|
40
|
-
if (existing && alive(existing.pid))
|
|
79
|
+
if (existing && existing.status !== "initialized" && alive(existing.pid))
|
|
41
80
|
throw new Error(`drive instance "${manifest.name}" is already running`)
|
|
42
81
|
await Promise.all([
|
|
43
82
|
rm(manifestPath(manifest.name), { force: true }),
|
|
@@ -55,23 +94,16 @@ export async function markStarting(name: string, pid: number) {
|
|
|
55
94
|
await markStatus(name, pid, "starting")
|
|
56
95
|
}
|
|
57
96
|
|
|
58
|
-
async function markStatus(
|
|
59
|
-
name: string,
|
|
60
|
-
pid: number,
|
|
61
|
-
status: InstanceManifest["status"],
|
|
62
|
-
) {
|
|
97
|
+
async function markStatus(name: string, pid: number, status: InstanceManifest["status"]) {
|
|
63
98
|
await withLock(name, true, async () => {
|
|
64
99
|
const manifest = await read(manifestPath(name))
|
|
65
|
-
if (!manifest || manifest.pid !== pid)
|
|
100
|
+
if (!manifest || manifest.status === "initialized" || manifest.pid !== pid)
|
|
66
101
|
throw new Error(`drive instance "${name}" changed ownership`)
|
|
67
102
|
await write({ ...manifest, status })
|
|
68
103
|
})
|
|
69
104
|
}
|
|
70
105
|
|
|
71
|
-
export async function resolveInstance(
|
|
72
|
-
name?: string,
|
|
73
|
-
options: { readonly ready?: boolean } = {},
|
|
74
|
-
) {
|
|
106
|
+
export async function resolveInstance(name?: string, options: { readonly ready?: boolean } = {}) {
|
|
75
107
|
const instances = await listInstances()
|
|
76
108
|
const manifest = name
|
|
77
109
|
? instances.find((item) => item.name === name)
|
|
@@ -84,9 +116,7 @@ export async function resolveInstance(
|
|
|
84
116
|
`multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`,
|
|
85
117
|
)
|
|
86
118
|
throw new Error(
|
|
87
|
-
name
|
|
88
|
-
? `drive instance "${name}" was not found`
|
|
89
|
-
: "no drive instances are running",
|
|
119
|
+
name ? `drive instance "${name}" was not found` : "no drive instances are running",
|
|
90
120
|
)
|
|
91
121
|
}
|
|
92
122
|
if (options.ready !== false && manifest.status !== "ready")
|
|
@@ -95,6 +125,12 @@ export async function resolveInstance(
|
|
|
95
125
|
}
|
|
96
126
|
|
|
97
127
|
export async function listInstances() {
|
|
128
|
+
return (await listManifests()).filter(
|
|
129
|
+
(manifest): manifest is InstanceManifest => manifest.status !== "initialized",
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function listManifests() {
|
|
98
134
|
await mkdir(registryDirectory(), { recursive: true })
|
|
99
135
|
const files = await readdir(registryDirectory())
|
|
100
136
|
const manifests = await Promise.all(
|
|
@@ -107,22 +143,21 @@ export async function listInstances() {
|
|
|
107
143
|
return undefined
|
|
108
144
|
}
|
|
109
145
|
const manifest = await read(join(registryDirectory(), file))
|
|
110
|
-
if (manifest?.name === name
|
|
111
|
-
|
|
146
|
+
if (manifest?.name === name) {
|
|
147
|
+
if (manifest.status === "initialized" || alive(manifest.pid)) return manifest
|
|
148
|
+
}
|
|
149
|
+
await prune(name, manifest?.status === "initialized" ? undefined : manifest?.pid)
|
|
112
150
|
return undefined
|
|
113
151
|
}),
|
|
114
152
|
)
|
|
115
|
-
const active = manifests.filter(
|
|
116
|
-
(manifest): manifest is InstanceManifest => manifest !== undefined,
|
|
117
|
-
)
|
|
153
|
+
const active = manifests.filter((manifest): manifest is Manifest => manifest !== undefined)
|
|
118
154
|
const names = new Set(active.map((manifest) => manifest.name))
|
|
119
155
|
await Promise.all(
|
|
120
156
|
files
|
|
121
157
|
.filter((file) => file.endsWith(".sock"))
|
|
122
158
|
.flatMap((file) => {
|
|
123
159
|
const name = basename(file, ".sock")
|
|
124
|
-
if (!validName(name))
|
|
125
|
-
return [rm(join(registryDirectory(), file), { force: true })]
|
|
160
|
+
if (!validName(name)) return [rm(join(registryDirectory(), file), { force: true })]
|
|
126
161
|
if (!names.has(name)) return [prune(name)]
|
|
127
162
|
return []
|
|
128
163
|
}),
|
|
@@ -133,7 +168,7 @@ export async function listInstances() {
|
|
|
133
168
|
export async function unregister(name: string, pid: number) {
|
|
134
169
|
await withLock(name, true, async () => {
|
|
135
170
|
const manifest = await read(manifestPath(name))
|
|
136
|
-
if (manifest
|
|
171
|
+
if (!manifest || manifest.status === "initialized" || manifest.pid !== pid) return
|
|
137
172
|
await Promise.all([
|
|
138
173
|
rm(manifestPath(name), { force: true }),
|
|
139
174
|
rm(controlPath(name), { force: true }),
|
|
@@ -144,6 +179,7 @@ export async function unregister(name: string, pid: number) {
|
|
|
144
179
|
async function prune(name: string, pid?: number) {
|
|
145
180
|
await withLock(name, true, async () => {
|
|
146
181
|
const manifest = await read(manifestPath(name))
|
|
182
|
+
if (manifest?.status === "initialized") return
|
|
147
183
|
if (manifest && (manifest.pid !== pid || alive(manifest.pid))) return
|
|
148
184
|
await Promise.all([
|
|
149
185
|
rm(manifestPath(name), { force: true }),
|
|
@@ -152,7 +188,7 @@ async function prune(name: string, pid?: number) {
|
|
|
152
188
|
})
|
|
153
189
|
}
|
|
154
190
|
|
|
155
|
-
async function write(manifest:
|
|
191
|
+
async function write(manifest: Manifest) {
|
|
156
192
|
const file = manifestPath(manifest.name)
|
|
157
193
|
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
158
194
|
try {
|
|
@@ -163,7 +199,7 @@ async function write(manifest: InstanceManifest) {
|
|
|
163
199
|
}
|
|
164
200
|
}
|
|
165
201
|
|
|
166
|
-
async function read(file: string): Promise<
|
|
202
|
+
async function read(file: string): Promise<Manifest | undefined> {
|
|
167
203
|
const value: unknown = await Bun.file(file)
|
|
168
204
|
.json()
|
|
169
205
|
.catch(() => undefined)
|
|
@@ -171,11 +207,7 @@ async function read(file: string): Promise<InstanceManifest | undefined> {
|
|
|
171
207
|
return undefined
|
|
172
208
|
}
|
|
173
209
|
|
|
174
|
-
async function withLock(
|
|
175
|
-
name: string,
|
|
176
|
-
wait: boolean,
|
|
177
|
-
task: () => Promise<void>,
|
|
178
|
-
) {
|
|
210
|
+
async function withLock(name: string, wait: boolean, task: () => Promise<void>) {
|
|
179
211
|
await mkdir(registryDirectory(), { recursive: true })
|
|
180
212
|
const lock = `${manifestPath(name)}.lock`
|
|
181
213
|
const deadline = Date.now() + 10_000
|
|
@@ -198,10 +230,8 @@ async function withLock(
|
|
|
198
230
|
await rm(lock, { force: true })
|
|
199
231
|
continue
|
|
200
232
|
}
|
|
201
|
-
if (!wait)
|
|
202
|
-
|
|
203
|
-
if (Date.now() >= deadline)
|
|
204
|
-
throw new Error(`timed out updating drive instance "${name}"`)
|
|
233
|
+
if (!wait) throw new Error(`drive instance "${name}" is already starting`)
|
|
234
|
+
if (Date.now() >= deadline) throw new Error(`timed out updating drive instance "${name}"`)
|
|
205
235
|
await Bun.sleep(10)
|
|
206
236
|
}
|
|
207
237
|
}
|
|
@@ -216,28 +246,28 @@ async function staleLock(file: string) {
|
|
|
216
246
|
return Number.isInteger(pid) && !alive(pid)
|
|
217
247
|
}
|
|
218
248
|
|
|
219
|
-
function isManifest(value: unknown): value is
|
|
249
|
+
function isManifest(value: unknown): value is Manifest {
|
|
220
250
|
if (typeof value !== "object" || value === null) return false
|
|
221
251
|
if (!("version" in value) || value.version !== 1) return false
|
|
222
252
|
if (!("name" in value) || typeof value.name !== "string") return false
|
|
253
|
+
if ("status" in value && value.status === "initialized")
|
|
254
|
+
return (
|
|
255
|
+
"createdAt" in value &&
|
|
256
|
+
typeof value.createdAt === "string" &&
|
|
257
|
+
"cwd" in value &&
|
|
258
|
+
typeof value.cwd === "string" &&
|
|
259
|
+
"artifacts" in value &&
|
|
260
|
+
typeof value.artifacts === "string"
|
|
261
|
+
)
|
|
223
262
|
if (!("pid" in value) || typeof value.pid !== "number") return false
|
|
224
|
-
if (!("startedAt" in value) || typeof value.startedAt !== "string")
|
|
225
|
-
return false
|
|
263
|
+
if (!("startedAt" in value) || typeof value.startedAt !== "string") return false
|
|
226
264
|
if (!("cwd" in value) || typeof value.cwd !== "string") return false
|
|
227
|
-
if (!("artifacts" in value) || typeof value.artifacts !== "string")
|
|
228
|
-
return false
|
|
265
|
+
if (!("artifacts" in value) || typeof value.artifacts !== "string") return false
|
|
229
266
|
if (!("visible" in value) || typeof value.visible !== "boolean") return false
|
|
230
|
-
if (
|
|
231
|
-
!("status" in value) ||
|
|
232
|
-
(value.status !== "starting" && value.status !== "ready")
|
|
233
|
-
)
|
|
267
|
+
if (!("status" in value) || (value.status !== "starting" && value.status !== "ready"))
|
|
234
268
|
return false
|
|
235
269
|
if (!("control" in value) || typeof value.control !== "string") return false
|
|
236
|
-
if (
|
|
237
|
-
!("endpoints" in value) ||
|
|
238
|
-
typeof value.endpoints !== "object" ||
|
|
239
|
-
value.endpoints === null
|
|
240
|
-
)
|
|
270
|
+
if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null)
|
|
241
271
|
return false
|
|
242
272
|
return (
|
|
243
273
|
"ui" in value.endpoints &&
|
package/src/cli/start.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { launchInstance } from "./instance.js"
|
|
1
|
+
import { initializeInstance, launchInstance } from "./instance.js"
|
|
2
2
|
import { mkdir, rm } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { connectSimulation } from "../client/index.js"
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
controlPath,
|
|
13
13
|
markReady,
|
|
14
14
|
markStarting,
|
|
15
|
+
initializeManifest,
|
|
15
16
|
register,
|
|
16
17
|
registryDirectory,
|
|
17
18
|
resolveInstance,
|
|
@@ -20,11 +21,12 @@ import {
|
|
|
20
21
|
import type { StartOptions } from "./types.js"
|
|
21
22
|
|
|
22
23
|
export async function start(options: StartOptions) {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
const initialized = await initializeManifest(options.name, process.cwd(), initializeInstance)
|
|
25
|
+
if (!options.visible && !options.script && !options.daemon) return startDetached(options)
|
|
25
26
|
const script = options.script ? await loadScript(options.script) : undefined
|
|
26
27
|
const responses = createResponseSettings()
|
|
27
28
|
const instance = await launchInstance({
|
|
29
|
+
artifacts: initialized.artifacts,
|
|
28
30
|
name: options.name,
|
|
29
31
|
command: options.command,
|
|
30
32
|
dev: options.dev,
|
|
@@ -56,8 +58,7 @@ export async function start(options: StartOptions) {
|
|
|
56
58
|
let driveReady = false
|
|
57
59
|
let recording: Promise<string | undefined> | undefined
|
|
58
60
|
const finishCurrentRecording = (onProgress?: (percent: number) => void) => {
|
|
59
|
-
if (!options.record || options.visible || !driveReady)
|
|
60
|
-
return Promise.resolve(undefined)
|
|
61
|
+
if (!options.record || options.visible || !driveReady) return Promise.resolve(undefined)
|
|
61
62
|
recording ??= finishRecording(instance, onProgress)
|
|
62
63
|
return recording
|
|
63
64
|
}
|
|
@@ -104,9 +105,7 @@ export async function start(options: StartOptions) {
|
|
|
104
105
|
driveReady = false
|
|
105
106
|
await instance.restart()
|
|
106
107
|
recording = undefined
|
|
107
|
-
current = run(options, instance, responses, script?.run, (path) =>
|
|
108
|
-
screenshots.push(path),
|
|
109
|
-
)
|
|
108
|
+
current = run(options, instance, responses, script?.run, (path) => screenshots.push(path))
|
|
110
109
|
await current.ready
|
|
111
110
|
driveReady = true
|
|
112
111
|
await markReady(options.name, process.pid)
|
|
@@ -123,9 +122,7 @@ export async function start(options: StartOptions) {
|
|
|
123
122
|
return responses.update(input)
|
|
124
123
|
},
|
|
125
124
|
})
|
|
126
|
-
current = run(options, instance, responses, script?.run, (path) =>
|
|
127
|
-
screenshots.push(path),
|
|
128
|
-
)
|
|
125
|
+
current = run(options, instance, responses, script?.run, (path) => screenshots.push(path))
|
|
129
126
|
await current.ready
|
|
130
127
|
driveReady = true
|
|
131
128
|
await markReady(options.name, process.pid)
|
|
@@ -170,10 +167,8 @@ export async function start(options: StartOptions) {
|
|
|
170
167
|
await closeControl?.()
|
|
171
168
|
await instance.stop()
|
|
172
169
|
await unregister(options.name, process.pid)
|
|
173
|
-
if (options.script && !options.visible)
|
|
174
|
-
|
|
175
|
-
if (options.script && recordingPath)
|
|
176
|
-
console.error(`opencode-drive: recording ${recordingPath}`)
|
|
170
|
+
if (options.script && !options.visible) report(instance, completed ? "completed" : undefined)
|
|
171
|
+
if (options.script && recordingPath) console.error(`opencode-drive: recording ${recordingPath}`)
|
|
177
172
|
}
|
|
178
173
|
}
|
|
179
174
|
|
|
@@ -187,7 +182,10 @@ async function finishRecording(
|
|
|
187
182
|
if (instance.child.exitCode !== null) {
|
|
188
183
|
timeline = expected.timeline
|
|
189
184
|
} else {
|
|
190
|
-
const ui = await connectSimulation({
|
|
185
|
+
const ui = await connectSimulation({
|
|
186
|
+
url: instance.endpoints.ui,
|
|
187
|
+
timeout: 60_000,
|
|
188
|
+
})
|
|
191
189
|
try {
|
|
192
190
|
timeline = await ui.finishRecording()
|
|
193
191
|
} finally {
|
|
@@ -204,8 +202,7 @@ async function finishRecording(
|
|
|
204
202
|
|
|
205
203
|
async function startDetached(options: StartOptions) {
|
|
206
204
|
const existing = await resolveInstance(options.name, { ready: false }).catch(() => undefined)
|
|
207
|
-
if (existing)
|
|
208
|
-
throw new Error(`drive instance "${options.name}" is already running`)
|
|
205
|
+
if (existing) throw new Error(`drive instance "${options.name}" is already running`)
|
|
209
206
|
const ownerLog = join(registryDirectory(), `${options.name}.log`)
|
|
210
207
|
await mkdir(registryDirectory(), { recursive: true })
|
|
211
208
|
await rm(ownerLog, { force: true })
|
|
@@ -242,15 +239,11 @@ async function startDetached(options: StartOptions) {
|
|
|
242
239
|
return
|
|
243
240
|
}
|
|
244
241
|
if (child.exitCode !== null)
|
|
245
|
-
throw new Error(
|
|
246
|
-
`detached instance exited with status ${child.exitCode}; see ${ownerLog}`,
|
|
247
|
-
)
|
|
242
|
+
throw new Error(`detached instance exited with status ${child.exitCode}; see ${ownerLog}`)
|
|
248
243
|
await Bun.sleep(50)
|
|
249
244
|
}
|
|
250
245
|
await terminateOwner(child)
|
|
251
|
-
throw new Error(
|
|
252
|
-
`timed out starting drive instance "${options.name}"; see ${ownerLog}`,
|
|
253
|
-
)
|
|
246
|
+
throw new Error(`timed out starting drive instance "${options.name}"; see ${ownerLog}`)
|
|
254
247
|
}
|
|
255
248
|
|
|
256
249
|
async function terminateOwner(child: Bun.Subprocess) {
|
|
@@ -306,7 +299,9 @@ function run(
|
|
|
306
299
|
}
|
|
307
300
|
const mock = await connectMockBackend(instance.endpoints.backend, responses)
|
|
308
301
|
ready()
|
|
309
|
-
abort.signal.addEventListener("abort", () => mock.close(), {
|
|
302
|
+
abort.signal.addEventListener("abort", () => mock.close(), {
|
|
303
|
+
once: true,
|
|
304
|
+
})
|
|
310
305
|
const status = await Promise.race([
|
|
311
306
|
child.exited,
|
|
312
307
|
new Promise<number>((resolve) =>
|
|
@@ -321,10 +316,7 @@ function run(
|
|
|
321
316
|
}
|
|
322
317
|
}
|
|
323
318
|
|
|
324
|
-
function report(
|
|
325
|
-
instance: { readonly artifacts: string; readonly logs: string },
|
|
326
|
-
status?: string,
|
|
327
|
-
) {
|
|
319
|
+
function report(instance: { readonly artifacts: string; readonly logs: string }, status?: string) {
|
|
328
320
|
if (status) console.error(`opencode-drive: ${status}`)
|
|
329
321
|
console.error(`opencode-drive: artifacts ${instance.artifacts}`)
|
|
330
322
|
}
|