opencode-drive 0.1.2 → 0.1.3

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.
@@ -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
- 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)
4
+ export async function restart(name: string) {
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,66 @@
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 Promise.race([
48
+ script({ ui, backend, artifacts, signal }),
49
+ new Promise<never>((_resolve, reject) => {
50
+ signal.addEventListener(
51
+ "abort",
52
+ () => reject(signal.reason ?? new Error("script restarted")),
53
+ { once: true },
54
+ )
55
+ }),
56
+ ])
57
+ } finally {
58
+ signal.removeEventListener("abort", abort)
59
+ ui.close()
60
+ backend.close()
61
+ }
62
+ }
63
+
64
+ function isDriveScript(value: unknown): value is DriveScript {
65
+ return typeof value === "function"
66
+ }
package/src/cli/send.ts CHANGED
@@ -1,48 +1,28 @@
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((await resolveInstance(options.name)).endpoints.ui, options.commands)
9
+ if (
10
+ options.commands.length === 1 &&
11
+ ["ui.screenshot", "ui.end-record"].includes(
12
+ options.commands[0]?.operation ?? "",
13
+ )
14
+ ) {
15
+ console.log(result.results[0]?.result)
24
16
  return
25
17
  }
26
- if (options.driver) {
27
- await runDriver(resolve(options.driver), manifest)
18
+ if (
19
+ options.commands.length === 1 &&
20
+ ["ui.state", "ui.start-record"].includes(
21
+ options.commands[0]?.operation ?? "",
22
+ )
23
+ ) {
24
+ console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
28
25
  return
29
26
  }
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
- }
27
+ console.log("success")
48
28
  }
package/src/cli/start.ts CHANGED
@@ -1,98 +1,123 @@
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"
2
+ import { connectMockBackend } from "./mock-backend.js"
3
+ import { runScript } from "./script.js"
4
+ import { listenControl } from "./control.js"
6
5
  import type { StartOptions } from "./types.js"
7
- import { join } from "node:path"
8
6
 
9
7
  export async function start(options: StartOptions) {
10
- if (options.campaign) return runCampaign(options)
11
8
  const instance = await launchInstance({
12
- name: options.name,
13
9
  command: options.command,
14
10
  dev: options.dev,
15
11
  state: options.state,
12
+ scripted: options.script !== undefined,
16
13
  visible: options.visible,
17
14
  })
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
- }
26
15
  const interrupt = () => void instance.stop()
27
- const stopRestart = options.visible ? watchVisibleRestarts(instance) : () => {}
16
+ let completed = false
17
+ let current: ReturnType<typeof run> | undefined
18
+ let restarting: Promise<void> | undefined
28
19
  process.once("SIGINT", interrupt)
29
20
  process.once("SIGTERM", interrupt)
21
+ const closeControl = options.visible
22
+ ? await listenControl(() => {
23
+ if (restarting) return restarting
24
+ restarting = (async () => {
25
+ const previous = current
26
+ previous?.abort.abort(new Error("script restarted"))
27
+ await previous?.promise.catch(() => undefined)
28
+ await instance.restart()
29
+ current = run(options, instance)
30
+ await current.ready
31
+ })().finally(() => {
32
+ restarting = undefined
33
+ })
34
+ return restarting
35
+ })
36
+ : undefined
30
37
  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")
38
+ current = run(options, instance)
39
+ if (options.visible) {
40
+ const status = await instance.wait()
41
+ if (status !== 0) process.exitCode = status
44
42
  return
45
43
  }
46
- if (options.driver) {
47
- await instance.waitForDrive("both")
48
- await runDriver(resolve(options.driver), instance.manifest)
49
- return
44
+ while (true) {
45
+ const active: NonNullable<typeof current> = current
46
+ await active.promise
47
+ if (active !== current) continue
48
+ completed = true
49
+ break
50
50
  }
51
- const status = await instance.wait()
52
- if (status !== 0) process.exitCode = status
53
51
  } finally {
54
52
  process.off("SIGINT", interrupt)
55
53
  process.off("SIGTERM", interrupt)
56
- stopRestart()
54
+ current?.abort.abort(new Error("opencode-drive stopped"))
55
+ await closeControl?.()
57
56
  await instance.stop()
57
+ if (options.script && !options.visible)
58
+ report(instance, completed ? "completed" : undefined)
58
59
  }
59
60
  }
60
61
 
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)
73
- }
74
-
75
- async function handleVisibleRestart(
62
+ function run(
63
+ options: StartOptions,
76
64
  instance: Awaited<ReturnType<typeof launchInstance>>,
77
- request: string,
78
- response: string,
79
- state: { token: string | undefined },
80
65
  ) {
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`)
66
+ const abort = new AbortController()
67
+ const child = instance.child
68
+ let ready!: () => void
69
+ const readiness = new Promise<void>((resolve) => {
70
+ ready = resolve
71
+ })
72
+ return {
73
+ abort,
74
+ ready: readiness,
75
+ promise: (async () => {
76
+ await instance.waitForDrive("both")
77
+ if (options.script) {
78
+ const script = runScript(
79
+ options.script,
80
+ instance.artifacts,
81
+ instance.endpoints,
82
+ abort.signal,
83
+ )
84
+ ready()
85
+ await script
86
+ if (options.visible) {
87
+ await Promise.race([
88
+ child.exited,
89
+ new Promise<void>((resolve) => {
90
+ abort.signal.addEventListener("abort", () => resolve(), {
91
+ once: true,
92
+ })
93
+ }),
94
+ ])
95
+ }
96
+ return
97
+ }
98
+ const mock = await connectMockBackend(instance.endpoints.backend)
99
+ ready()
100
+ abort.signal.addEventListener("abort", () => mock.close(), { once: true })
101
+ if (!options.visible) report(instance)
102
+ const status = await Promise.race([
103
+ child.exited,
104
+ new Promise<number>((resolve) => {
105
+ abort.signal.addEventListener("abort", () => resolve(0), {
106
+ once: true,
107
+ })
108
+ }),
109
+ ])
110
+ mock.close()
111
+ if (status !== 0 && !abort.signal.aborted) process.exitCode = status
112
+ })(),
93
113
  }
94
114
  }
95
115
 
96
- function isRestartRequest(value: unknown): value is { readonly token: string } {
97
- return typeof value === "object" && value !== null && "token" in value && typeof value.token === "string"
116
+ function report(
117
+ instance: Awaited<ReturnType<typeof launchInstance>>,
118
+ status?: string,
119
+ ) {
120
+ if (status) console.error(`opencode-drive: ${status}`)
121
+ console.error(`opencode-drive: artifacts ${instance.artifacts}`)
122
+ console.error(`opencode-drive: logs ${instance.logs}`)
98
123
  }
package/src/cli/stop.ts CHANGED
@@ -1,31 +1,7 @@
1
- import { resolveInstance, unregisterInstance } from "./registry.js"
1
+ import { request } from "./control.js"
2
+ import { resolveInstance } from "./registry.js"
2
3
 
3
- 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)
4
+ export async function stop(name: string) {
5
+ await request((await resolveInstance(name)).control, "stop")
11
6
  console.log("success")
12
7
  }
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)
19
- }
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
30
- }
31
- }
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