opencode-drive 0.1.1 → 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.
@@ -0,0 +1,25 @@
1
+ import { connectBackendSimulation } from "../client/index.js"
2
+
3
+ const response = "This is a sample response from opencode-drive."
4
+
5
+ export async function connectMockBackend(endpoint: string) {
6
+ const backend = await connectBackendSimulation({ url: endpoint })
7
+ let closing = false
8
+ await backend.attach((request) => {
9
+ void backend
10
+ .chunk(request.id, [{ type: "textDelta", text: response }])
11
+ .then(() => backend.finish(request.id))
12
+ .catch((error) => {
13
+ if (!closing)
14
+ console.error(
15
+ `error: ${error instanceof Error ? error.message : String(error)}`,
16
+ )
17
+ })
18
+ })
19
+ return {
20
+ close() {
21
+ closing = true
22
+ backend.close()
23
+ },
24
+ }
25
+ }
@@ -1,92 +1,54 @@
1
+ import { mkdir, rm } from "node:fs/promises"
1
2
  import { homedir } from "node:os"
2
- import { basename, join } from "node:path"
3
- import { mkdir, open, readdir, rename, rm } from "node:fs/promises"
4
- import type { InstanceManifest } from "./types.js"
3
+ import { join } from "node:path"
4
+
5
+ export interface InstanceManifest {
6
+ readonly version: 1
7
+ readonly name: string
8
+ readonly pid: number
9
+ readonly artifacts: string
10
+ readonly visible: boolean
11
+ readonly endpoints: { readonly ui: string; readonly backend: string }
12
+ readonly control: string
13
+ }
5
14
 
6
15
  export function registryDirectory() {
7
- if (process.env.DRIVE_REGISTRY_DIR) return process.env.DRIVE_REGISTRY_DIR
8
- return join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances")
16
+ return process.env.DRIVE_REGISTRY_DIR ?? join(homedir(), ".local", "state", "opencode-drive", "instances")
9
17
  }
10
18
 
11
19
  export function manifestPath(name: string) {
12
20
  return join(registryDirectory(), `${validateName(name)}.json`)
13
21
  }
14
22
 
15
- export async function registerInstance(manifest: InstanceManifest) {
16
- await mkdir(registryDirectory(), { recursive: true })
17
- const file = manifestPath(manifest.name)
18
- const lock = `${file}.lock`
19
- const existing = await readManifest(file)
20
- if (existing && processAlive(existing.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
21
- if (existing) await rm(file, { force: true })
22
- const handle = await open(lock, "wx").catch(() => undefined)
23
- if (!handle) throw new Error(`drive instance "${manifest.name}" is already starting`)
24
- try {
25
- const current = await readManifest(file)
26
- if (current && processAlive(current.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
27
- const temporary = `${file}.${process.pid}.tmp`
28
- await Bun.write(temporary, `${JSON.stringify(manifest, undefined, 2)}\n`)
29
- await rename(temporary, file)
30
- return file
31
- } finally {
32
- await handle.close()
33
- await rm(lock, { force: true })
34
- }
23
+ export function controlPath(name: string) {
24
+ return join(registryDirectory(), `${validateName(name)}.sock`)
35
25
  }
36
26
 
37
- export async function unregisterInstance(name: string, pid: number) {
38
- const file = manifestPath(name)
39
- const manifest = await readManifest(file)
40
- if (manifest?.pid !== pid) return
41
- await rm(file, { force: true })
42
- }
43
-
44
- export async function transferInstance(manifest: InstanceManifest, pid: number) {
45
- const file = manifestPath(manifest.name)
46
- const current = await readManifest(file)
47
- if (current?.pid !== manifest.pid) throw new Error(`drive instance "${manifest.name}" changed ownership`)
48
- const temporary = `${file}.${process.pid}.tmp`
49
- await Bun.write(temporary, `${JSON.stringify({ ...manifest, pid }, undefined, 2)}\n`)
50
- await rename(temporary, file)
27
+ export async function register(manifest: InstanceManifest) {
28
+ await mkdir(registryDirectory(), { recursive: true })
29
+ const existing = await read(manifest.name).catch(() => undefined)
30
+ if (existing && alive(existing.pid)) throw new Error(`drive instance "${manifest.name}" is already running`)
31
+ await rm(manifest.control, { force: true })
32
+ await Bun.write(manifestPath(manifest.name), `${JSON.stringify(manifest, undefined, 2)}\n`)
51
33
  }
52
34
 
53
- export async function resolveInstance(name?: string) {
54
- const instances = await listInstances()
55
- if (name) {
56
- const instance = instances.find((item) => item.name === name)
57
- if (!instance) throw new Error(`drive instance "${name}" was not found`)
58
- return instance
35
+ export async function resolveInstance(name = "default") {
36
+ const manifest = await read(name)
37
+ if (!alive(manifest.pid)) {
38
+ await unregister(name)
39
+ throw new Error(`drive instance "${name}" is not running`)
59
40
  }
60
- if (instances.length === 1) return instances[0]!
61
- if (instances.length === 0) throw new Error("no drive instances are running")
62
- throw new Error(`multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`)
41
+ return manifest
63
42
  }
64
43
 
65
- export async function listInstances() {
66
- await mkdir(registryDirectory(), { recursive: true })
67
- const directory = registryDirectory()
68
- const files = (await readdir(directory)).filter((file) => file.endsWith(".json"))
69
- const entries = await Promise.all(files.map((file) => readManifest(join(directory, file))))
70
- const active = entries.filter((entry): entry is InstanceManifest => entry !== undefined && processAlive(entry.pid))
71
- const activeNames = new Set(active.map((entry) => entry.name))
72
- await Promise.all(
73
- files
74
- .filter((file) => !activeNames.has(basename(file, ".json")))
75
- .map((file) => rm(join(directory, file), { force: true })),
76
- )
77
- return active.sort((a, b) => a.name.localeCompare(b.name))
44
+ export async function unregister(name: string) {
45
+ await Promise.all([rm(manifestPath(name), { force: true }), rm(controlPath(name), { force: true })])
78
46
  }
79
47
 
80
- async function readManifest(file: string): Promise<InstanceManifest | undefined> {
81
- if (!(await Bun.file(file).exists())) return undefined
82
- try {
83
- const value = await Bun.file(file).json()
84
- if (!isManifest(value)) return undefined
85
- return value
86
- } catch {
87
- await rm(file, { force: true })
88
- return undefined
89
- }
48
+ async function read(name: string): Promise<InstanceManifest> {
49
+ const value: unknown = await Bun.file(manifestPath(name)).json().catch(() => undefined)
50
+ if (!isManifest(value)) throw new Error(`drive instance "${name}" was not found`)
51
+ return value
90
52
  }
91
53
 
92
54
  function isManifest(value: unknown): value is InstanceManifest {
@@ -94,16 +56,19 @@ function isManifest(value: unknown): value is InstanceManifest {
94
56
  if (!("version" in value) || value.version !== 1) return false
95
57
  if (!("name" in value) || typeof value.name !== "string") return false
96
58
  if (!("pid" in value) || typeof value.pid !== "number") return false
97
- if (!("startedAt" in value) || typeof value.startedAt !== "string") return false
98
- if (!("mode" in value) || (value.mode !== "simulated" && value.mode !== "real")) return false
99
- if (!("headless" in value) || typeof value.headless !== "boolean") return false
100
- if (!("cwd" in value) || typeof value.cwd !== "string") return false
101
59
  if (!("artifacts" in value) || typeof value.artifacts !== "string") return false
60
+ if (!("visible" in value) || typeof value.visible !== "boolean") return false
61
+ if (!("control" in value) || typeof value.control !== "string") return false
102
62
  if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
103
63
  return "ui" in value.endpoints && typeof value.endpoints.ui === "string" && "backend" in value.endpoints && typeof value.endpoints.backend === "string"
104
64
  }
105
65
 
106
- function processAlive(pid: number) {
66
+ function validateName(name: string) {
67
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`invalid instance name "${name}"`)
68
+ return name
69
+ }
70
+
71
+ function alive(pid: number) {
107
72
  try {
108
73
  process.kill(pid, 0)
109
74
  return true
@@ -111,10 +76,3 @@ function processAlive(pid: number) {
111
76
  return false
112
77
  }
113
78
  }
114
-
115
- function validateName(name: string) {
116
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) {
117
- throw new Error("instance names must contain 1-64 letters, numbers, dots, underscores, or dashes")
118
- }
119
- return name
120
- }
@@ -0,0 +1,7 @@
1
+ import { request } from "./control.js"
2
+ import { resolveInstance } from "./registry.js"
3
+
4
+ export async function restart(name: string) {
5
+ await request((await resolveInstance(name)).control, "restart")
6
+ console.log("success")
7
+ }
@@ -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 && ["render", "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", "state", "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,56 +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
6
 
8
7
  export async function start(options: StartOptions) {
9
- if (options.campaign) return runCampaign(options)
10
8
  const instance = await launchInstance({
11
- name: options.name,
12
9
  command: options.command,
13
10
  dev: options.dev,
14
11
  state: options.state,
12
+ scripted: options.script !== undefined,
15
13
  visible: options.visible,
16
14
  })
17
- console.error(`opencode-drive: ${instance.manifest.name}`)
18
- console.error(`opencode-drive: artifacts ${instance.manifest.artifacts}`)
19
- console.error(`opencode-drive: send commands with opencode-drive send --name ${instance.manifest.name}`)
20
- if (options.detach) {
21
- await instance.waitForDrive("both")
22
- await instance.detach()
23
- return
24
- }
25
15
  const interrupt = () => void instance.stop()
16
+ let completed = false
17
+ let current: ReturnType<typeof run> | undefined
18
+ let restarting: Promise<void> | undefined
26
19
  process.once("SIGINT", interrupt)
27
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
28
37
  try {
29
- if (options.commands.length > 0) {
30
- await instance.waitForDrive("both")
31
- const result = await executeCommands(instance.manifest, options.commands)
32
- await instance.stop()
33
- if (options.commands.length === 1 && ["render", "end-record"].includes(options.commands[0]?.operation ?? "")) {
34
- console.log(result.results[0]?.result)
35
- return
36
- }
37
- if (options.commands.length === 1 && ["llm.pending", "state", "start-record"].includes(options.commands[0]?.operation ?? "")) {
38
- console.log(JSON.stringify(result.results[0]?.result, undefined, 2))
39
- return
40
- }
41
- 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
42
42
  return
43
43
  }
44
- if (options.driver) {
45
- await instance.waitForDrive("both")
46
- await runDriver(resolve(options.driver), instance.manifest)
47
- 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
48
50
  }
49
- const status = await instance.child.exited
50
- if (status !== 0) process.exitCode = status
51
51
  } finally {
52
52
  process.off("SIGINT", interrupt)
53
53
  process.off("SIGTERM", interrupt)
54
+ current?.abort.abort(new Error("opencode-drive stopped"))
55
+ await closeControl?.()
54
56
  await instance.stop()
57
+ if (options.script && !options.visible)
58
+ report(instance, completed ? "completed" : undefined)
55
59
  }
56
60
  }
61
+
62
+ function run(
63
+ options: StartOptions,
64
+ instance: Awaited<ReturnType<typeof launchInstance>>,
65
+ ) {
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
+ })(),
113
+ }
114
+ }
115
+
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}`)
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