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 CHANGED
@@ -2,14 +2,15 @@
2
2
 
3
3
  This project gives your agents control over OpenCode:
4
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
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-drive",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Drive real and simulated OpenCode instances",
6
6
  "license": "MIT",
7
7
  "type": "module",
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
  }
@@ -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
+ }
@@ -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 launchInstance(options: LaunchOptions) {
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
- 'export function greet(name) {\n return `Hello, ${name}.`\n}\n',
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
- ? Bun.file(join(logs, "opencode.stdout.log"))
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
- ? [endpoints.ui, endpoints.backend]
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
- throw new Error(`bun install failed in ${cwd} with status ${status}`)
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
- "error",
360
- () => reject(new Error(`cannot connect to ${url}`)),
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 { listInstances, manifestPath } from "./registry.js"
1
+ import { listManifests, manifestPath } from "./registry.js"
2
2
 
3
3
  export async function list() {
4
- const instances = await listInstances()
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
  }
@@ -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 && alive(manifest.pid)) return manifest
111
- await prune(name, manifest?.pid)
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?.pid !== pid) return
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: InstanceManifest) {
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<InstanceManifest | undefined> {
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
- throw new Error(`drive instance "${name}" is already starting`)
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 InstanceManifest {
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
- if (!options.visible && !options.script && !options.daemon)
24
- return startDetached(options)
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
- report(instance, completed ? "completed" : undefined)
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({ url: instance.endpoints.ui, timeout: 60_000 })
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(), { once: true })
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
  }