opencode-drive 0.1.3 → 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.
package/src/cli/send.ts CHANGED
@@ -5,7 +5,10 @@ import { resolveInstance } from "./registry.js"
5
5
  export async function send(options: SendOptions) {
6
6
  if (options.commands.length === 0)
7
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)
8
+ const result = await executeCommands(
9
+ (await resolveInstance(options.name)).endpoints.ui,
10
+ options.commands,
11
+ )
9
12
  if (
10
13
  options.commands.length === 1 &&
11
14
  ["ui.screenshot", "ui.end-record"].includes(
package/src/cli/start.ts CHANGED
@@ -1,49 +1,101 @@
1
1
  import { launchInstance } from "./instance.js"
2
+ import { mkdir, rm } from "node:fs/promises"
3
+ import { join } from "node:path"
2
4
  import { connectMockBackend } from "./mock-backend.js"
5
+ import { createResponseSettings } from "./response-generator.js"
3
6
  import { runScript } from "./script.js"
4
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"
5
17
  import type { StartOptions } from "./types.js"
6
18
 
7
19
  export async function start(options: StartOptions) {
20
+ if (!options.visible && !options.script && !options.daemon)
21
+ return startDetached(options)
22
+ const responses = createResponseSettings()
8
23
  const instance = await launchInstance({
24
+ name: options.name,
9
25
  command: options.command,
10
26
  dev: options.dev,
11
27
  state: options.state,
12
28
  scripted: options.script !== undefined,
13
29
  visible: options.visible,
14
30
  })
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
+ })
15
46
  const interrupt = () => void instance.stop()
16
47
  let completed = false
17
48
  let current: ReturnType<typeof run> | undefined
18
49
  let restarting: Promise<void> | undefined
50
+ let stopping = false
19
51
  process.once("SIGINT", interrupt)
20
52
  process.once("SIGTERM", interrupt)
21
- const closeControl = options.visible
22
- ? await listenControl(() => {
53
+ let closeControl: (() => Promise<void>) | undefined
54
+ try {
55
+ closeControl = await listenControl(controlPath(options.name), {
56
+ restart: () => {
23
57
  if (restarting) return restarting
24
58
  restarting = (async () => {
59
+ await markStarting(options.name, process.pid)
25
60
  const previous = current
26
61
  previous?.abort.abort(new Error("script restarted"))
27
62
  await previous?.promise.catch(() => undefined)
28
63
  await instance.restart()
29
- current = run(options, instance)
64
+ current = run(options, instance, responses)
30
65
  await current.ready
66
+ await markReady(options.name, process.pid)
31
67
  })().finally(() => {
32
68
  restarting = undefined
33
69
  })
34
70
  return restarting
35
- })
36
- : undefined
37
- try {
38
- current = run(options, instance)
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)
39
86
  if (options.visible) {
40
87
  const status = await instance.wait()
41
- if (status !== 0) process.exitCode = status
88
+ if (status !== 0 && !stopping) process.exitCode = status
42
89
  return
43
90
  }
44
91
  while (true) {
45
92
  const active: NonNullable<typeof current> = current
46
93
  await active.promise
94
+ if (stopping) break
95
+ if (restarting) {
96
+ await restarting
97
+ continue
98
+ }
47
99
  if (active !== current) continue
48
100
  completed = true
49
101
  break
@@ -54,14 +106,76 @@ export async function start(options: StartOptions) {
54
106
  current?.abort.abort(new Error("opencode-drive stopped"))
55
107
  await closeControl?.()
56
108
  await instance.stop()
109
+ await unregister(options.name, process.pid)
57
110
  if (options.script && !options.visible)
58
111
  report(instance, completed ? "completed" : undefined)
59
112
  }
60
113
  }
61
114
 
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
173
+ }
174
+
62
175
  function run(
63
176
  options: StartOptions,
64
177
  instance: Awaited<ReturnType<typeof launchInstance>>,
178
+ responses: ReturnType<typeof createResponseSettings>,
65
179
  ) {
66
180
  const abort = new AbortController()
67
181
  const child = instance.child
@@ -86,26 +200,25 @@ function run(
86
200
  if (options.visible) {
87
201
  await Promise.race([
88
202
  child.exited,
89
- new Promise<void>((resolve) => {
203
+ new Promise<void>((resolve) =>
90
204
  abort.signal.addEventListener("abort", () => resolve(), {
91
205
  once: true,
92
- })
93
- }),
206
+ }),
207
+ ),
94
208
  ])
95
209
  }
96
210
  return
97
211
  }
98
- const mock = await connectMockBackend(instance.endpoints.backend)
212
+ const mock = await connectMockBackend(instance.endpoints.backend, responses)
99
213
  ready()
100
214
  abort.signal.addEventListener("abort", () => mock.close(), { once: true })
101
- if (!options.visible) report(instance)
102
215
  const status = await Promise.race([
103
216
  child.exited,
104
- new Promise<number>((resolve) => {
217
+ new Promise<number>((resolve) =>
105
218
  abort.signal.addEventListener("abort", () => resolve(0), {
106
219
  once: true,
107
- })
108
- }),
220
+ }),
221
+ ),
109
222
  ])
110
223
  mock.close()
111
224
  if (status !== 0 && !abort.signal.aborted) process.exitCode = status
@@ -114,10 +227,9 @@ function run(
114
227
  }
115
228
 
116
229
  function report(
117
- instance: Awaited<ReturnType<typeof launchInstance>>,
230
+ instance: { readonly artifacts: string; readonly logs: string },
118
231
  status?: string,
119
232
  ) {
120
233
  if (status) console.error(`opencode-drive: ${status}`)
121
234
  console.error(`opencode-drive: artifacts ${instance.artifacts}`)
122
- console.error(`opencode-drive: logs ${instance.logs}`)
123
235
  }
package/src/cli/stop.ts CHANGED
@@ -1,7 +1,24 @@
1
1
  import { request } from "./control.js"
2
- import { resolveInstance } from "./registry.js"
2
+ import { manifestPath, resolveInstance } from "./registry.js"
3
3
 
4
- export async function stop(name: string) {
5
- await request((await resolveInstance(name)).control, "stop")
6
- console.log("success")
4
+ export async function stop(name?: string) {
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
20
+ }
21
+ await Bun.sleep(25)
22
+ }
23
+ throw new Error(`timed out stopping drive instance "${manifest.name}"`)
7
24
  }
package/src/cli/types.ts CHANGED
@@ -16,7 +16,7 @@ export interface StartOptions {
16
16
 
17
17
  export interface SendOptions {
18
18
  readonly kind: "send"
19
- readonly name: string
19
+ readonly name?: string
20
20
  readonly commands: ReadonlyArray<DriveCommand>
21
21
  }
22
22
 
@@ -159,7 +159,7 @@ export class BackendSimulationClient {
159
159
 
160
160
  close() {
161
161
  this.closing = true
162
- this.socket.close()
162
+ this.socket.terminate()
163
163
  }
164
164
 
165
165
  private onMessage(data: string) {
@@ -1,21 +1,48 @@
1
- import {
2
- Frontend,
3
- type JsonRpc,
4
- } from "./protocol.js"
1
+ import { Frontend, type JsonRpc } from "./protocol.js"
5
2
 
6
3
  const defaultPort = 40900
7
4
 
8
5
  type Methods = {
9
- readonly "ui.screenshot": { readonly params: undefined; readonly result: Frontend.Screenshot }
10
- readonly "ui.state": { readonly params: undefined; readonly result: Frontend.State }
11
- readonly "ui.start-record": { readonly params: undefined; readonly result: Frontend.StartRecord }
12
- readonly "ui.end-record": { readonly params: undefined; readonly result: Frontend.EndRecord }
13
- readonly "ui.type": { readonly params: Frontend.TypeParams; readonly result: Frontend.State }
14
- readonly "ui.press": { readonly params: Frontend.PressParams; readonly result: Frontend.State }
15
- readonly "ui.enter": { readonly params: undefined; readonly result: Frontend.State }
16
- readonly "ui.arrow": { readonly params: Frontend.ArrowParams; readonly result: Frontend.State }
17
- readonly "ui.focus": { readonly params: Frontend.FocusParams; readonly result: Frontend.State }
18
- readonly "ui.click": { readonly params: Frontend.ClickParams; readonly result: Frontend.State }
6
+ readonly "ui.screenshot": {
7
+ readonly params: undefined
8
+ readonly result: Frontend.Screenshot
9
+ }
10
+ readonly "ui.state": {
11
+ readonly params: undefined
12
+ readonly result: Frontend.State
13
+ }
14
+ readonly "ui.start-record": {
15
+ readonly params: undefined
16
+ readonly result: Frontend.StartRecord
17
+ }
18
+ readonly "ui.end-record": {
19
+ readonly params: undefined
20
+ readonly result: Frontend.EndRecord
21
+ }
22
+ readonly "ui.type": {
23
+ readonly params: Frontend.TypeParams
24
+ readonly result: Frontend.State
25
+ }
26
+ readonly "ui.press": {
27
+ readonly params: Frontend.PressParams
28
+ readonly result: Frontend.State
29
+ }
30
+ readonly "ui.enter": {
31
+ readonly params: undefined
32
+ readonly result: Frontend.State
33
+ }
34
+ readonly "ui.arrow": {
35
+ readonly params: Frontend.ArrowParams
36
+ readonly result: Frontend.State
37
+ }
38
+ readonly "ui.focus": {
39
+ readonly params: Frontend.FocusParams
40
+ readonly result: Frontend.State
41
+ }
42
+ readonly "ui.click": {
43
+ readonly params: Frontend.ClickParams
44
+ readonly result: Frontend.State
45
+ }
19
46
  }
20
47
 
21
48
  type MethodName = keyof Methods
@@ -73,12 +100,20 @@ export class SimulationClient {
73
100
  this.socket = socket
74
101
  this.url = url
75
102
  this.timeout = timeout
76
- socket.addEventListener("message", (event) => this.onMessage(String(event.data)))
77
- socket.addEventListener("close", () => this.rejectAll(new SimulationError("connection closed")))
78
- socket.addEventListener("error", () => this.rejectAll(new SimulationError("connection error")))
103
+ socket.addEventListener("message", (event) =>
104
+ this.onMessage(String(event.data)),
105
+ )
106
+ socket.addEventListener("close", () =>
107
+ this.rejectAll(new SimulationError("connection closed")),
108
+ )
109
+ socket.addEventListener("error", () =>
110
+ this.rejectAll(new SimulationError("connection error")),
111
+ )
79
112
  }
80
113
 
81
- static async connect(options?: SimulationClientOptions): Promise<SimulationClient> {
114
+ static async connect(
115
+ options?: SimulationClientOptions,
116
+ ): Promise<SimulationClient> {
82
117
  const timeout = options?.timeout ?? 30_000
83
118
  if (options?.url !== undefined) {
84
119
  return new SimulationClient(await open(options.url), options.url, timeout)
@@ -100,7 +135,10 @@ export class SimulationClient {
100
135
  }
101
136
 
102
137
  /** Raw JSON-RPC call. Prefer the typed wrappers below. */
103
- async call<M extends MethodName>(method: M, params?: Methods[M]["params"]): Promise<Methods[M]["result"]> {
138
+ async call<M extends MethodName>(
139
+ method: M,
140
+ params?: Methods[M]["params"],
141
+ ): Promise<Methods[M]["result"]> {
104
142
  if (this.socket.readyState !== WebSocket.OPEN) {
105
143
  throw new SimulationError("connection is not open", method)
106
144
  }
@@ -112,7 +150,14 @@ export class SimulationClient {
112
150
  }, this.timeout)
113
151
  this.pending.set(id, { method, resolve, reject, timer })
114
152
  })
115
- this.socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }))
153
+ this.socket.send(
154
+ JSON.stringify({
155
+ jsonrpc: "2.0",
156
+ id,
157
+ method,
158
+ ...(params === undefined ? {} : { params }),
159
+ }),
160
+ )
116
161
  // The server contract types each method's result; the cast happens once
117
162
  // here rather than at every call site.
118
163
  return (await promise) as Methods[M]["result"]
@@ -142,15 +187,23 @@ export class SimulationClient {
142
187
  return this.call("ui.type", { text })
143
188
  }
144
189
 
145
- pressKey(key: string, modifiers?: Frontend.KeyModifiers): Promise<Frontend.State> {
146
- return this.call("ui.press", { key: key === "escape" ? "\u001b" : key, ...(modifiers === undefined ? {} : { modifiers }) })
190
+ pressKey(
191
+ key: string,
192
+ modifiers?: Frontend.KeyModifiers,
193
+ ): Promise<Frontend.State> {
194
+ return this.call("ui.press", {
195
+ key: key === "escape" ? "\u001b" : key,
196
+ ...(modifiers === undefined ? {} : { modifiers }),
197
+ })
147
198
  }
148
199
 
149
200
  pressEnter(): Promise<Frontend.State> {
150
201
  return this.call("ui.enter")
151
202
  }
152
203
 
153
- pressArrow(direction: "up" | "down" | "left" | "right"): Promise<Frontend.State> {
204
+ pressArrow(
205
+ direction: "up" | "down" | "left" | "right",
206
+ ): Promise<Frontend.State> {
154
207
  return this.call("ui.arrow", { direction })
155
208
  }
156
209
 
@@ -165,7 +218,7 @@ export class SimulationClient {
165
218
  // ── lifecycle ─────────────────────────────────────────────────────────
166
219
 
167
220
  close(): void {
168
- this.socket.close()
221
+ this.socket.terminate()
169
222
  }
170
223
 
171
224
  private onMessage(data: string) {
@@ -175,7 +228,8 @@ export class SimulationClient {
175
228
  if (waiter === undefined) return
176
229
  this.pending.delete(message.id)
177
230
  clearTimeout(waiter.timer)
178
- if (message.error) waiter.reject(new SimulationError(message.error.message, waiter.method))
231
+ if (message.error)
232
+ waiter.reject(new SimulationError(message.error.message, waiter.method))
179
233
  else waiter.resolve(message.result)
180
234
  }
181
235
 
@@ -199,14 +253,16 @@ function parseResponse(data: string): JsonRpc.Response | undefined {
199
253
  if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") return undefined
200
254
  if (!("id" in value)) return undefined
201
255
  const id = value.id
202
- if (typeof id !== "number" && typeof id !== "string" && id !== null) return undefined
256
+ if (typeof id !== "number" && typeof id !== "string" && id !== null)
257
+ return undefined
203
258
  const result = "result" in value ? value.result : undefined
204
259
  const error = "error" in value ? value.error : undefined
205
260
  if (error !== undefined) {
206
261
  if (typeof error !== "object" || error === null) return undefined
207
262
  const code = "code" in error ? error.code : undefined
208
263
  const message = "message" in error ? error.message : undefined
209
- if (typeof code !== "number" || typeof message !== "string") return undefined
264
+ if (typeof code !== "number" || typeof message !== "string")
265
+ return undefined
210
266
  return { jsonrpc: "2.0", id, error: { code, message } }
211
267
  }
212
268
  return { jsonrpc: "2.0", id, result: result as JsonRpc.Response["result"] }
@@ -232,5 +288,6 @@ function open(url: string): Promise<WebSocket> {
232
288
  })
233
289
  }
234
290
 
235
- export const connectSimulation = (options?: SimulationClientOptions): Promise<SimulationClient> =>
236
- SimulationClient.connect(options)
291
+ export const connectSimulation = (
292
+ options?: SimulationClientOptions,
293
+ ): Promise<SimulationClient> => SimulationClient.connect(options)
@@ -191,6 +191,7 @@ export namespace Backend {
191
191
  }),
192
192
  Schema.Struct({
193
193
  type: Schema.Literal("toolCall"),
194
+ index: Schema.Number,
194
195
  id: Schema.String,
195
196
  name: Schema.String,
196
197
  input: Schema.Json,
@@ -1,13 +0,0 @@
1
- import { resolveInstance } from "./registry.js"
2
-
3
- export async function describe(name: string) {
4
- const manifest = await resolveInstance(name)
5
- console.log([
6
- `PID: ${manifest.pid}`,
7
- `Visible: ${manifest.visible}`,
8
- `Artifacts: ${manifest.artifacts}`,
9
- `UI: ${manifest.endpoints.ui}`,
10
- `Backend: ${manifest.endpoints.backend}`,
11
- `Logs: ${manifest.artifacts}/logs`,
12
- ].join("\n"))
13
- }