opencode-drive 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import { requestResponses } from "./control.js"
2
+ import { resolveInstance } from "./registry.js"
3
+
4
+ export async function responses(options: {
5
+ readonly name?: string
6
+ readonly types?: string
7
+ readonly tools?: string
8
+ }) {
9
+ const manifest = await resolveInstance(options.name)
10
+ const configuration = await requestResponses(manifest.control, {
11
+ ...(options.types === undefined ? {} : { types: split(options.types) }),
12
+ ...(options.tools === undefined ? {} : { tools: split(options.tools) }),
13
+ })
14
+ console.log(`Types: ${configuration.types.join(",")}`)
15
+ console.log(`Tools: ${configuration.tools.join(",")}`)
16
+ }
17
+
18
+ function split(value: string) {
19
+ return value.split(",").map((item) => item.trim())
20
+ }
@@ -1,70 +1,7 @@
1
- import { randomUUID } from "node:crypto"
2
- import { join } from "node:path"
3
- import { restartInstance } from "./instance.js"
1
+ import { request } from "./control.js"
4
2
  import { resolveInstance } from "./registry.js"
5
3
 
6
4
  export async function restart(name?: string) {
7
- const manifest = await resolveInstance(name ?? "default")
8
- if (!manifest.headless) {
9
- await restartVisible(manifest)
10
- console.log("success")
11
- return
12
- }
13
- process.kill(manifest.pid, "SIGTERM")
14
- await Promise.race([waitForExit(manifest.pid), Bun.sleep(1_000)])
15
- if (alive(manifest.pid)) process.kill(manifest.pid, "SIGKILL")
16
- await waitForExit(manifest.pid)
17
- await restartInstance(manifest)
5
+ await request((await resolveInstance(name)).control, "restart")
18
6
  console.log("success")
19
7
  }
20
-
21
- async function restartVisible(manifest: Awaited<ReturnType<typeof resolveInstance>>) {
22
- if (!(await Bun.file(join(manifest.artifacts, "launch.json")).exists())) {
23
- throw new Error(`drive instance "${manifest.name}" was started by a version that does not support restart`)
24
- }
25
- const token = randomUUID()
26
- const request = join(manifest.artifacts, "restart-request.json")
27
- const response = join(manifest.artifacts, "restart-response.json")
28
- await Bun.file(response).delete().catch(() => undefined)
29
- await Bun.write(request, `${JSON.stringify({ token })}\n`)
30
- const deadline = Date.now() + 60_000
31
- while (Date.now() < deadline) {
32
- const value = await Bun.file(response).json().catch(() => undefined)
33
- if (isRestartResponse(value) && value.token === token) {
34
- if (!value.success) throw new Error(value.error ?? "visible restart failed")
35
- return
36
- }
37
- if (!alive(manifest.pid)) throw new Error(`drive instance "${manifest.name}" exited while restarting`)
38
- await Bun.sleep(25)
39
- }
40
- throw new Error(`timed out restarting drive instance "${manifest.name}"`)
41
- }
42
-
43
- function isRestartResponse(value: unknown): value is {
44
- readonly token: string
45
- readonly success: boolean
46
- readonly error?: string
47
- } {
48
- if (typeof value !== "object" || value === null) return false
49
- if (!("token" in value) || typeof value.token !== "string") return false
50
- return "success" in value && typeof value.success === "boolean"
51
- }
52
-
53
- function waitForExit(pid: number) {
54
- return new Promise<void>((resolve) => {
55
- const check = () => {
56
- if (!alive(pid)) return resolve()
57
- setTimeout(check, 25)
58
- }
59
- check()
60
- })
61
- }
62
-
63
- function alive(pid: number) {
64
- try {
65
- process.kill(pid, 0)
66
- return true
67
- } catch {
68
- return false
69
- }
70
- }
@@ -0,0 +1,77 @@
1
+ import { resolve } from "node:path"
2
+ import { pathToFileURL } from "node:url"
3
+ import { connectBackendSimulation, connectSimulation } from "../client/index.js"
4
+ import type {
5
+ BackendSimulationClient,
6
+ SimulationClient,
7
+ } from "../client/index.js"
8
+
9
+ export interface ScriptContext {
10
+ readonly ui: SimulationClient
11
+ readonly backend: BackendSimulationClient
12
+ readonly artifacts: string
13
+ readonly signal: AbortSignal
14
+ }
15
+
16
+ export type DriveScript = (context: ScriptContext) => void | Promise<void>
17
+
18
+ export function defineScript(script: DriveScript) {
19
+ return script
20
+ }
21
+
22
+ export async function runScript(
23
+ file: string,
24
+ artifacts: string,
25
+ endpoints: { readonly ui: string; readonly backend: string },
26
+ signal: AbortSignal,
27
+ ) {
28
+ const module: { readonly default?: unknown } = await import(
29
+ pathToFileURL(resolve(file)).href
30
+ )
31
+ const script = module.default
32
+ if (!isDriveScript(script))
33
+ throw new Error("script must default-export a function")
34
+ const ui = await connectSimulation({ url: endpoints.ui })
35
+ const backend = await connectBackendSimulation({
36
+ url: endpoints.backend,
37
+ }).catch((error) => {
38
+ ui.close()
39
+ throw error
40
+ })
41
+ const abort = () => {
42
+ ui.close()
43
+ backend.close()
44
+ }
45
+ signal.addEventListener("abort", abort, { once: true })
46
+ try {
47
+ await waitForEditor(ui, signal)
48
+ await Promise.race([
49
+ script({ ui, backend, artifacts, signal }),
50
+ new Promise<never>((_resolve, reject) => {
51
+ signal.addEventListener(
52
+ "abort",
53
+ () => reject(signal.reason ?? new Error("script restarted")),
54
+ { once: true },
55
+ )
56
+ }),
57
+ ])
58
+ } finally {
59
+ signal.removeEventListener("abort", abort)
60
+ ui.close()
61
+ backend.close()
62
+ }
63
+ }
64
+
65
+ async function waitForEditor(ui: SimulationClient, signal: AbortSignal) {
66
+ const deadline = Date.now() + 30_000
67
+ while (Date.now() < deadline) {
68
+ signal.throwIfAborted()
69
+ if ((await ui.state()).focused.editor) return
70
+ await Bun.sleep(50)
71
+ }
72
+ throw new Error("timed out waiting for the prompt editor")
73
+ }
74
+
75
+ function isDriveScript(value: unknown): value is DriveScript {
76
+ return typeof value === "function"
77
+ }
package/src/cli/send.ts CHANGED
@@ -1,48 +1,31 @@
1
- import { tmpdir } from "node:os"
2
- import { join, resolve } from "node:path"
3
- import { defaultBackendPort, defaultPort } from "../client/index.js"
4
1
  import { executeCommands } from "./commands.js"
5
- import { runDriver } from "./driver.js"
6
- import { resolveInstance } from "./registry.js"
7
2
  import type { SendOptions } from "./types.js"
3
+ import { resolveInstance } from "./registry.js"
8
4
 
9
5
  export async function send(options: SendOptions) {
10
- const manifest = options.name
11
- ? await resolveInstance(options.name)
12
- : await resolveInstance("default").catch(() => defaultManifest())
13
- if (options.commands.length > 0) {
14
- const result = await executeCommands(manifest, options.commands)
15
- if (options.commands.length === 1 && ["ui.screenshot", "ui.end-record"].includes(options.commands[0]?.operation ?? "")) {
16
- console.log(result.results[0]?.result)
17
- return
18
- }
19
- if (options.commands.length === 1 && ["llm.pending", "ui.state", "ui.start-record"].includes(options.commands[0]?.operation ?? "")) {
20
- console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
21
- return
22
- }
23
- console.log("success")
6
+ if (options.commands.length === 0)
7
+ throw new Error("send requires at least one --command.ui.* flag")
8
+ const result = await executeCommands(
9
+ (await resolveInstance(options.name)).endpoints.ui,
10
+ options.commands,
11
+ )
12
+ if (
13
+ options.commands.length === 1 &&
14
+ ["ui.screenshot", "ui.end-record"].includes(
15
+ options.commands[0]?.operation ?? "",
16
+ )
17
+ ) {
18
+ console.log(result.results[0]?.result)
24
19
  return
25
20
  }
26
- if (options.driver) {
27
- await runDriver(resolve(options.driver), manifest)
21
+ if (
22
+ options.commands.length === 1 &&
23
+ ["ui.state", "ui.start-record"].includes(
24
+ options.commands[0]?.operation ?? "",
25
+ )
26
+ ) {
27
+ console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
28
28
  return
29
29
  }
30
- console.log(JSON.stringify(manifest, undefined, 2))
31
- }
32
-
33
- function defaultManifest() {
34
- return {
35
- version: 1 as const,
36
- name: "default",
37
- pid: 0,
38
- startedAt: new Date().toISOString(),
39
- mode: "real" as const,
40
- headless: false,
41
- cwd: process.cwd(),
42
- artifacts: join(tmpdir(), "opencode-drive", "default"),
43
- endpoints: {
44
- ui: `ws://127.0.0.1:${defaultPort}`,
45
- backend: `ws://127.0.0.1:${defaultBackendPort}`,
46
- },
47
- }
30
+ console.log("success")
48
31
  }
package/src/cli/start.ts CHANGED
@@ -1,98 +1,235 @@
1
- import { resolve } from "node:path"
2
- import { executeCommands } from "./commands.js"
3
- import { runCampaign } from "../experimental/cli-campaign.js"
4
- import { runDriver } from "./driver.js"
5
1
  import { launchInstance } from "./instance.js"
6
- import type { StartOptions } from "./types.js"
2
+ import { mkdir, rm } from "node:fs/promises"
7
3
  import { join } from "node:path"
4
+ import { connectMockBackend } from "./mock-backend.js"
5
+ import { createResponseSettings } from "./response-generator.js"
6
+ import { runScript } from "./script.js"
7
+ import { listenControl } from "./control.js"
8
+ import {
9
+ controlPath,
10
+ markReady,
11
+ markStarting,
12
+ register,
13
+ registryDirectory,
14
+ resolveInstance,
15
+ unregister,
16
+ } from "./registry.js"
17
+ import type { StartOptions } from "./types.js"
8
18
 
9
19
  export async function start(options: StartOptions) {
10
- if (options.campaign) return runCampaign(options)
20
+ if (!options.visible && !options.script && !options.daemon)
21
+ return startDetached(options)
22
+ const responses = createResponseSettings()
11
23
  const instance = await launchInstance({
12
24
  name: options.name,
13
25
  command: options.command,
14
26
  dev: options.dev,
15
27
  state: options.state,
28
+ scripted: options.script !== undefined,
16
29
  visible: options.visible,
17
30
  })
18
- console.error(`opencode-drive: ${instance.manifest.name}`)
19
- console.error(`opencode-drive: artifacts ${instance.manifest.artifacts}`)
20
- console.error(`opencode-drive: send commands with opencode-drive send --name ${instance.manifest.name}`)
21
- if (options.detach) {
22
- await instance.waitForDrive("both")
23
- await instance.detach()
24
- return
25
- }
31
+ await register({
32
+ version: 1,
33
+ name: options.name,
34
+ pid: process.pid,
35
+ startedAt: new Date().toISOString(),
36
+ cwd: process.cwd(),
37
+ artifacts: instance.artifacts,
38
+ visible: options.visible,
39
+ status: "starting",
40
+ endpoints: instance.endpoints,
41
+ control: controlPath(options.name),
42
+ }).catch(async (error) => {
43
+ await instance.stop()
44
+ throw error
45
+ })
26
46
  const interrupt = () => void instance.stop()
27
- const stopRestart = options.visible ? watchVisibleRestarts(instance) : () => {}
47
+ let completed = false
48
+ let current: ReturnType<typeof run> | undefined
49
+ let restarting: Promise<void> | undefined
50
+ let stopping = false
28
51
  process.once("SIGINT", interrupt)
29
52
  process.once("SIGTERM", interrupt)
53
+ let closeControl: (() => Promise<void>) | undefined
30
54
  try {
31
- if (options.commands.length > 0) {
32
- await instance.waitForDrive("both")
33
- const result = await executeCommands(instance.manifest, options.commands)
34
- await instance.stop()
35
- if (options.commands.length === 1 && ["ui.screenshot", "ui.end-record"].includes(options.commands[0]?.operation ?? "")) {
36
- console.log(result.results[0]?.result)
37
- return
38
- }
39
- if (options.commands.length === 1 && ["llm.pending", "ui.state", "ui.start-record"].includes(options.commands[0]?.operation ?? "")) {
40
- console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
41
- return
42
- }
43
- console.log("success")
55
+ closeControl = await listenControl(controlPath(options.name), {
56
+ restart: () => {
57
+ if (restarting) return restarting
58
+ restarting = (async () => {
59
+ await markStarting(options.name, process.pid)
60
+ const previous = current
61
+ previous?.abort.abort(new Error("script restarted"))
62
+ await previous?.promise.catch(() => undefined)
63
+ await instance.restart()
64
+ current = run(options, instance, responses)
65
+ await current.ready
66
+ await markReady(options.name, process.pid)
67
+ })().finally(() => {
68
+ restarting = undefined
69
+ })
70
+ return restarting
71
+ },
72
+ stop: async () => {
73
+ stopping = true
74
+ current?.abort.abort(new Error("opencode-drive stopped"))
75
+ await instance.stop()
76
+ },
77
+ responses: async (input) => {
78
+ if (options.script)
79
+ throw new Error("responses are unavailable when --script owns the simulation backend")
80
+ return responses.update(input)
81
+ },
82
+ })
83
+ current = run(options, instance, responses)
84
+ await current.ready
85
+ await markReady(options.name, process.pid)
86
+ if (options.visible) {
87
+ const status = await instance.wait()
88
+ if (status !== 0 && !stopping) process.exitCode = status
44
89
  return
45
90
  }
46
- if (options.driver) {
47
- await instance.waitForDrive("both")
48
- await runDriver(resolve(options.driver), instance.manifest)
49
- return
91
+ while (true) {
92
+ const active: NonNullable<typeof current> = current
93
+ await active.promise
94
+ if (stopping) break
95
+ if (restarting) {
96
+ await restarting
97
+ continue
98
+ }
99
+ if (active !== current) continue
100
+ completed = true
101
+ break
50
102
  }
51
- const status = await instance.wait()
52
- if (status !== 0) process.exitCode = status
53
103
  } finally {
54
104
  process.off("SIGINT", interrupt)
55
105
  process.off("SIGTERM", interrupt)
56
- stopRestart()
106
+ current?.abort.abort(new Error("opencode-drive stopped"))
107
+ await closeControl?.()
57
108
  await instance.stop()
109
+ await unregister(options.name, process.pid)
110
+ if (options.script && !options.visible)
111
+ report(instance, completed ? "completed" : undefined)
58
112
  }
59
113
  }
60
114
 
61
- function watchVisibleRestarts(instance: Awaited<ReturnType<typeof launchInstance>>) {
62
- const request = join(instance.manifest.artifacts, "restart-request.json")
63
- const response = join(instance.manifest.artifacts, "restart-response.json")
64
- const state = { token: undefined as string | undefined, busy: false }
65
- const timer = setInterval(() => {
66
- if (state.busy) return
67
- state.busy = true
68
- void handleVisibleRestart(instance, request, response, state).finally(() => {
69
- state.busy = false
70
- })
71
- }, 50)
72
- return () => clearInterval(timer)
115
+ async function startDetached(options: StartOptions) {
116
+ const existing = await resolveInstance(options.name, { ready: false }).catch(() => undefined)
117
+ if (existing)
118
+ throw new Error(`drive instance "${options.name}" is already running`)
119
+ const ownerLog = join(registryDirectory(), `${options.name}.log`)
120
+ await mkdir(registryDirectory(), { recursive: true })
121
+ await rm(ownerLog, { force: true })
122
+ const child = Bun.spawn(
123
+ [
124
+ process.execPath,
125
+ process.argv[1]!,
126
+ "start",
127
+ "--daemon",
128
+ "--name",
129
+ options.name,
130
+ ...(options.script ? ["--script", options.script] : []),
131
+ ...(options.dev ? ["--dev", options.dev] : []),
132
+ ...(options.state ? ["--state", options.state] : []),
133
+ ...(options.command.length ? ["--", ...options.command] : []),
134
+ ],
135
+ {
136
+ cwd: process.cwd(),
137
+ env: process.env,
138
+ stdin: "ignore",
139
+ stdout: "ignore",
140
+ stderr: Bun.file(ownerLog),
141
+ },
142
+ )
143
+ child.unref()
144
+ const deadline = Date.now() + 60_000
145
+ while (Date.now() < deadline) {
146
+ const manifest = await resolveInstance(options.name).catch(() => undefined)
147
+ if (manifest?.pid === child.pid) {
148
+ report({
149
+ artifacts: manifest.artifacts,
150
+ logs: `${manifest.artifacts}/logs`,
151
+ })
152
+ return
153
+ }
154
+ if (child.exitCode !== null)
155
+ throw new Error(
156
+ `detached instance exited with status ${child.exitCode}; see ${ownerLog}`,
157
+ )
158
+ await Bun.sleep(50)
159
+ }
160
+ await terminateOwner(child)
161
+ throw new Error(
162
+ `timed out starting drive instance "${options.name}"; see ${ownerLog}`,
163
+ )
164
+ }
165
+
166
+ async function terminateOwner(child: Bun.Subprocess) {
167
+ if (child.exitCode !== null) return
168
+ child.kill("SIGTERM")
169
+ const deadline = Date.now() + 1_000
170
+ while (child.exitCode === null && Date.now() < deadline) await Bun.sleep(25)
171
+ if (child.exitCode === null) child.kill("SIGKILL")
172
+ await child.exited
73
173
  }
74
174
 
75
- async function handleVisibleRestart(
175
+ function run(
176
+ options: StartOptions,
76
177
  instance: Awaited<ReturnType<typeof launchInstance>>,
77
- request: string,
78
- response: string,
79
- state: { token: string | undefined },
178
+ responses: ReturnType<typeof createResponseSettings>,
80
179
  ) {
81
- const value: unknown = await Bun.file(request).json().catch(() => undefined)
82
- if (!isRestartRequest(value) || value.token === state.token) return
83
- state.token = value.token
84
- try {
85
- await instance.restart()
86
- await Bun.write(response, `${JSON.stringify({ token: value.token, success: true })}\n`)
87
- } catch (error) {
88
- await Bun.write(response, `${JSON.stringify({
89
- token: value.token,
90
- success: false,
91
- error: error instanceof Error ? error.message : String(error),
92
- })}\n`)
180
+ const abort = new AbortController()
181
+ const child = instance.child
182
+ let ready!: () => void
183
+ const readiness = new Promise<void>((resolve) => {
184
+ ready = resolve
185
+ })
186
+ return {
187
+ abort,
188
+ ready: readiness,
189
+ promise: (async () => {
190
+ await instance.waitForDrive("both")
191
+ if (options.script) {
192
+ const script = runScript(
193
+ options.script,
194
+ instance.artifacts,
195
+ instance.endpoints,
196
+ abort.signal,
197
+ )
198
+ ready()
199
+ await script
200
+ if (options.visible) {
201
+ await Promise.race([
202
+ child.exited,
203
+ new Promise<void>((resolve) =>
204
+ abort.signal.addEventListener("abort", () => resolve(), {
205
+ once: true,
206
+ }),
207
+ ),
208
+ ])
209
+ }
210
+ return
211
+ }
212
+ const mock = await connectMockBackend(instance.endpoints.backend, responses)
213
+ ready()
214
+ abort.signal.addEventListener("abort", () => mock.close(), { once: true })
215
+ const status = await Promise.race([
216
+ child.exited,
217
+ new Promise<number>((resolve) =>
218
+ abort.signal.addEventListener("abort", () => resolve(0), {
219
+ once: true,
220
+ }),
221
+ ),
222
+ ])
223
+ mock.close()
224
+ if (status !== 0 && !abort.signal.aborted) process.exitCode = status
225
+ })(),
93
226
  }
94
227
  }
95
228
 
96
- function isRestartRequest(value: unknown): value is { readonly token: string } {
97
- return typeof value === "object" && value !== null && "token" in value && typeof value.token === "string"
229
+ function report(
230
+ instance: { readonly artifacts: string; readonly logs: string },
231
+ status?: string,
232
+ ) {
233
+ if (status) console.error(`opencode-drive: ${status}`)
234
+ console.error(`opencode-drive: artifacts ${instance.artifacts}`)
98
235
  }
package/src/cli/stop.ts CHANGED
@@ -1,31 +1,24 @@
1
- import { resolveInstance, unregisterInstance } from "./registry.js"
1
+ import { request } from "./control.js"
2
+ import { manifestPath, resolveInstance } from "./registry.js"
2
3
 
3
4
  export async function stop(name?: string) {
4
- const manifest = await resolveInstance(name ?? "default")
5
- if (!manifest.headless) throw new Error(`drive instance "${manifest.name}" is visible`)
6
- process.kill(manifest.pid, "SIGTERM")
7
- await Promise.race([waitForExit(manifest.pid), Bun.sleep(1_000)])
8
- if (alive(manifest.pid)) process.kill(manifest.pid, "SIGKILL")
9
- await waitForExit(manifest.pid)
10
- await unregisterInstance(manifest.name, manifest.pid)
11
- console.log("success")
12
- }
13
-
14
- function waitForExit(pid: number) {
15
- return new Promise<void>((resolve) => {
16
- const check = () => {
17
- if (!alive(pid)) return resolve()
18
- setTimeout(check, 25)
5
+ const manifest = await resolveInstance(name)
6
+ await request(manifest.control, "stop")
7
+ const deadline = Date.now() + 10_000
8
+ while (Date.now() < deadline) {
9
+ const current: unknown = await Bun.file(manifestPath(manifest.name))
10
+ .json()
11
+ .catch(() => undefined)
12
+ if (
13
+ typeof current !== "object" ||
14
+ current === null ||
15
+ !("pid" in current) ||
16
+ current.pid !== manifest.pid
17
+ ) {
18
+ console.log("success")
19
+ return
19
20
  }
20
- check()
21
- })
22
- }
23
-
24
- function alive(pid: number) {
25
- try {
26
- process.kill(pid, 0)
27
- return true
28
- } catch {
29
- return false
21
+ await Bun.sleep(25)
30
22
  }
23
+ throw new Error(`timed out stopping drive instance "${manifest.name}"`)
31
24
  }
package/src/cli/types.ts CHANGED
@@ -1,46 +1,23 @@
1
- export interface InstanceManifest {
2
- readonly version: 1
3
- readonly name: string
4
- readonly pid: number
5
- readonly startedAt: string
6
- readonly mode: "simulated" | "real"
7
- readonly headless: boolean
8
- readonly cwd: string
9
- readonly artifacts: string
10
- readonly endpoints: {
11
- readonly ui: string
12
- readonly backend: string
13
- }
14
- }
15
-
16
1
  export interface DriveCommand {
17
2
  readonly operation: string
18
3
  readonly value?: string
19
4
  }
20
5
 
21
- export interface CommonOptions {
22
- readonly name?: string
23
- readonly driver?: string
24
- readonly commands: ReadonlyArray<DriveCommand>
25
- }
26
-
27
- export interface StartOptions extends CommonOptions {
6
+ export interface StartOptions {
28
7
  readonly kind: "start"
29
- readonly detach: boolean
30
- readonly campaign?: string
31
- readonly seed: number
32
- readonly caseIndex?: number
33
- readonly count?: number
34
- readonly concurrency: number
8
+ readonly name: string
9
+ readonly daemon: boolean
10
+ readonly script?: string
35
11
  readonly visible: boolean
36
12
  readonly dev?: string
37
13
  readonly state?: string
38
- readonly anchor?: string
39
14
  readonly command: ReadonlyArray<string>
40
15
  }
41
16
 
42
- export interface SendOptions extends CommonOptions {
17
+ export interface SendOptions {
43
18
  readonly kind: "send"
19
+ readonly name?: string
20
+ readonly commands: ReadonlyArray<DriveCommand>
44
21
  }
45
22
 
46
23
  export type CliOptions = StartOptions | SendOptions