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.
- package/README.md +57 -38
- package/package.json +1 -1
- package/src/cli/commands.ts +101 -68
- package/src/cli/control.ts +43 -0
- package/src/cli/describe.ts +4 -5
- package/src/cli/index.ts +83 -110
- package/src/cli/instance.ts +193 -148
- package/src/cli/mock-backend.ts +25 -0
- package/src/cli/registry.ts +41 -83
- package/src/cli/restart.ts +3 -66
- package/src/cli/script.ts +66 -0
- package/src/cli/send.ts +19 -39
- package/src/cli/start.ts +93 -68
- package/src/cli/stop.ts +4 -28
- package/src/cli/types.ts +7 -30
- package/src/client/backend.ts +94 -32
- package/src/client/protocol.ts +115 -29
- package/src/client/protocol.types.ts +17 -23
- package/src/experimental/driver.ts +29 -18
- package/src/experimental/flow-driver.ts +130 -43
- package/src/experimental/flows/properties.ts +19 -13
- package/src/experimental/hello-driver.ts +11 -4
- package/src/experimental/stale-running-driver.ts +45 -13
- package/src/index.ts +2 -0
- package/src/cli/driver-runner.ts +0 -39
- package/src/cli/driver.ts +0 -28
- package/src/experimental/cli-campaign.ts +0 -179
package/src/cli/instance.ts
CHANGED
|
@@ -1,47 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
2
2
|
import { tmpdir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import {
|
|
5
|
-
import type { InstanceManifest } from "./types.js"
|
|
4
|
+
import { defaultBackendPort, defaultPort } from "../client/index.js"
|
|
6
5
|
|
|
7
6
|
export interface LaunchOptions {
|
|
8
|
-
readonly name
|
|
7
|
+
readonly name: string
|
|
9
8
|
readonly command?: ReadonlyArray<string>
|
|
10
9
|
readonly dev?: string
|
|
11
10
|
readonly state?: string
|
|
11
|
+
readonly scripted?: boolean
|
|
12
12
|
readonly visible?: boolean
|
|
13
13
|
readonly env?: Readonly<Record<string, string>>
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function launchInstance(options: LaunchOptions = {}) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
17
|
+
const artifacts = resolve(
|
|
18
|
+
join(tmpdir(), "opencode-drive", `run-${crypto.randomUUID().slice(0, 6)}`),
|
|
19
|
+
)
|
|
20
|
+
const logs = join(artifacts, "logs")
|
|
21
21
|
const endpoints = {
|
|
22
|
-
ui: `ws://127.0.0.1:${
|
|
23
|
-
backend: `ws://127.0.0.1:${
|
|
24
|
-
}
|
|
25
|
-
const manifest: InstanceManifest = {
|
|
26
|
-
version: 1,
|
|
27
|
-
name,
|
|
28
|
-
pid: process.pid,
|
|
29
|
-
startedAt: new Date().toISOString(),
|
|
30
|
-
mode: "simulated",
|
|
31
|
-
headless: options.visible !== true,
|
|
32
|
-
cwd,
|
|
33
|
-
artifacts,
|
|
34
|
-
endpoints,
|
|
22
|
+
ui: `ws://127.0.0.1:${await freePort()}`,
|
|
23
|
+
backend: `ws://127.0.0.1:${await freePort()}`,
|
|
35
24
|
}
|
|
25
|
+
const drive = join(artifacts, "drive")
|
|
36
26
|
await Promise.all([
|
|
37
|
-
mkdir(
|
|
38
|
-
mkdir(
|
|
27
|
+
mkdir(logs, { recursive: true }),
|
|
28
|
+
mkdir(drive, { recursive: true }),
|
|
39
29
|
mkdir(join(artifacts, "home", ".cache"), { recursive: true }),
|
|
40
30
|
mkdir(join(artifacts, "home", ".config"), { recursive: true }),
|
|
41
31
|
mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
|
|
42
32
|
mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
|
|
43
33
|
])
|
|
44
|
-
|
|
34
|
+
await Bun.write(join(drive, `${options.name}.json`), `${JSON.stringify({ endpoints }, undefined, 2)}\n`)
|
|
35
|
+
const state = options.state
|
|
36
|
+
? resolve(options.state)
|
|
37
|
+
: join(artifacts, "state")
|
|
45
38
|
if (!options.state) {
|
|
46
39
|
await rm(state, { recursive: true, force: true })
|
|
47
40
|
await Promise.all([
|
|
@@ -57,16 +50,17 @@ export async function launchInstance(options: LaunchOptions = {}) {
|
|
|
57
50
|
providers: {
|
|
58
51
|
simulation: {
|
|
59
52
|
name: "Simulation",
|
|
53
|
+
package: "aisdk:@ai-sdk/openai-compatible",
|
|
54
|
+
settings: { baseURL: "https://api.openai.com/v1" },
|
|
60
55
|
request: { body: { apiKey: "sim-key" } },
|
|
61
56
|
models: {
|
|
62
57
|
"sim-model": {
|
|
63
58
|
name: "Simulated Model",
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
59
|
+
capabilities: {
|
|
60
|
+
tools: true,
|
|
61
|
+
input: ["text"],
|
|
62
|
+
output: ["text"],
|
|
68
63
|
},
|
|
69
|
-
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
70
64
|
limit: { context: 128000, output: 16000 },
|
|
71
65
|
},
|
|
72
66
|
},
|
|
@@ -78,96 +72,98 @@ export async function launchInstance(options: LaunchOptions = {}) {
|
|
|
78
72
|
)}\n`,
|
|
79
73
|
)
|
|
80
74
|
}
|
|
81
|
-
await registerInstance(manifest)
|
|
82
75
|
const environment = cleanEnv({
|
|
83
76
|
...process.env,
|
|
84
77
|
...options.env,
|
|
85
|
-
DRIVE_REGISTRY_DIR: registryDirectory(),
|
|
86
78
|
OPENCODE_SIMULATE: "1",
|
|
87
79
|
OPENCODE_SIMULATE_STATE: state,
|
|
88
|
-
|
|
80
|
+
DRIVE_REGISTRY_DIR: drive,
|
|
81
|
+
OPENCODE_DRIVE: options.name,
|
|
89
82
|
OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
|
|
90
|
-
OPENCODE_CONFIG_DIR: join(
|
|
83
|
+
OPENCODE_CONFIG_DIR: join(artifacts, ".opencode"),
|
|
91
84
|
OPENCODE_DB: ":memory:",
|
|
85
|
+
OPENCODE_LOG_LEVEL: !options.visible
|
|
86
|
+
? "INFO"
|
|
87
|
+
: process.env.OPENCODE_LOG_LEVEL,
|
|
92
88
|
OPENCODE_TEST_HOME: artifacts,
|
|
93
89
|
XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
|
|
94
90
|
XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
|
|
95
|
-
XDG_DATA_HOME:
|
|
91
|
+
XDG_DATA_HOME: logs,
|
|
96
92
|
XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
|
|
97
93
|
})
|
|
98
94
|
const command = options.dev
|
|
99
|
-
? await prepareDev(
|
|
95
|
+
? await prepareDev(artifacts, options.dev)
|
|
100
96
|
: options.command?.length
|
|
101
97
|
? [...options.command]
|
|
102
98
|
: ["opencode2"]
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
99
|
+
const spawn = () =>
|
|
100
|
+
Bun.spawn(command, {
|
|
101
|
+
cwd: artifacts,
|
|
102
|
+
env: environment,
|
|
103
|
+
stdin: options.visible ? "inherit" : "ignore",
|
|
104
|
+
stdout: !options.visible
|
|
105
|
+
? Bun.file(join(logs, "opencode.stdout.log"))
|
|
106
|
+
: "inherit",
|
|
107
|
+
stderr: !options.visible
|
|
108
|
+
? Bun.file(join(logs, "opencode.stderr.log"))
|
|
109
|
+
: "inherit",
|
|
110
|
+
})
|
|
111
|
+
let child = spawn()
|
|
112
|
+
let stopping: Promise<void> | undefined
|
|
113
|
+
let restarting: Promise<void> | undefined
|
|
113
114
|
return {
|
|
114
|
-
|
|
115
|
+
artifacts,
|
|
116
|
+
logs,
|
|
117
|
+
endpoints,
|
|
115
118
|
get child() {
|
|
116
119
|
return child
|
|
117
120
|
},
|
|
118
|
-
async waitForDrive(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
async waitForDrive(
|
|
122
|
+
requirement: "ui" | "backend" | "both" = "both",
|
|
123
|
+
timeout = 60_000,
|
|
124
|
+
) {
|
|
125
|
+
const urls =
|
|
126
|
+
requirement === "both"
|
|
127
|
+
? [endpoints.ui, endpoints.backend]
|
|
128
|
+
: [endpoints[requirement]]
|
|
129
|
+
await Promise.all(
|
|
130
|
+
urls.map((url) => waitForWebSocket(url, child.exited, timeout)),
|
|
131
|
+
)
|
|
128
132
|
},
|
|
129
|
-
restart() {
|
|
130
|
-
if (
|
|
131
|
-
|
|
133
|
+
async restart() {
|
|
134
|
+
if (restarting) return restarting
|
|
135
|
+
restarting = (async () => {
|
|
132
136
|
await terminate(child)
|
|
133
|
-
child = spawn(
|
|
137
|
+
child = spawn()
|
|
134
138
|
await Promise.all([
|
|
135
139
|
waitForWebSocket(endpoints.ui, child.exited, 60_000),
|
|
136
140
|
waitForWebSocket(endpoints.backend, child.exited, 60_000),
|
|
137
141
|
])
|
|
138
142
|
})().finally(() => {
|
|
139
|
-
|
|
143
|
+
restarting = undefined
|
|
140
144
|
})
|
|
141
|
-
return
|
|
145
|
+
return restarting
|
|
142
146
|
},
|
|
143
147
|
async wait() {
|
|
144
148
|
while (true) {
|
|
145
149
|
const current = child
|
|
146
150
|
const status = await current.exited
|
|
147
|
-
if (
|
|
148
|
-
await
|
|
151
|
+
if (restarting) {
|
|
152
|
+
await restarting
|
|
149
153
|
continue
|
|
150
154
|
}
|
|
151
|
-
if (
|
|
155
|
+
if (current !== child) continue
|
|
152
156
|
return status
|
|
153
157
|
}
|
|
154
158
|
},
|
|
155
|
-
stop(
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (child.exitCode === null) child.kill("SIGKILL")
|
|
162
|
-
} else if (child.exitCode === null) {
|
|
163
|
-
child.kill("SIGTERM")
|
|
164
|
-
await Promise.race([child.exited, Bun.sleep(1_000)])
|
|
165
|
-
}
|
|
166
|
-
if (child.exitCode === null) child.kill("SIGKILL")
|
|
167
|
-
await child.exited
|
|
168
|
-
await unregisterInstance(name, process.pid)
|
|
159
|
+
stop() {
|
|
160
|
+
if (stopping) return stopping
|
|
161
|
+
stopping = (async () => {
|
|
162
|
+
if (restarting) await restarting.catch(() => undefined)
|
|
163
|
+
await terminate(child)
|
|
164
|
+
await stopService(join(artifacts, "home", ".local", "state"))
|
|
169
165
|
})()
|
|
170
|
-
return
|
|
166
|
+
return stopping
|
|
171
167
|
},
|
|
172
168
|
}
|
|
173
169
|
}
|
|
@@ -180,67 +176,48 @@ async function terminate(child: Bun.Subprocess) {
|
|
|
180
176
|
await child.exited
|
|
181
177
|
}
|
|
182
178
|
|
|
183
|
-
export async function restartInstance(manifest: InstanceManifest) {
|
|
184
|
-
const value: unknown = await Bun.file(join(manifest.artifacts, "launch.json")).json()
|
|
185
|
-
if (!isLaunchInfo(value)) throw new Error(`drive instance "${manifest.name}" has no valid restart metadata`)
|
|
186
|
-
const child = spawn(value.command, value.environment, manifest)
|
|
187
|
-
await transferInstance(manifest, child.pid)
|
|
188
|
-
try {
|
|
189
|
-
await Promise.all([
|
|
190
|
-
waitForWebSocket(manifest.endpoints.ui, child.exited, 60_000),
|
|
191
|
-
waitForWebSocket(manifest.endpoints.backend, child.exited, 60_000),
|
|
192
|
-
])
|
|
193
|
-
child.unref()
|
|
194
|
-
return child.pid
|
|
195
|
-
} catch (error) {
|
|
196
|
-
if (child.exitCode === null) child.kill("SIGKILL")
|
|
197
|
-
await child.exited
|
|
198
|
-
throw error
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function spawn(command: ReadonlyArray<string>, environment: Readonly<Record<string, string>>, manifest: InstanceManifest) {
|
|
203
|
-
return Bun.spawn([...command], {
|
|
204
|
-
cwd: manifest.cwd,
|
|
205
|
-
env: environment,
|
|
206
|
-
stdin: manifest.headless ? "ignore" : "inherit",
|
|
207
|
-
stdout: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stdout.log")) : "inherit",
|
|
208
|
-
stderr: manifest.headless ? Bun.file(join(manifest.artifacts, "opencode.stderr.log")) : "inherit",
|
|
209
|
-
})
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isLaunchInfo(value: unknown): value is {
|
|
213
|
-
readonly command: ReadonlyArray<string>
|
|
214
|
-
readonly environment: Readonly<Record<string, string>>
|
|
215
|
-
} {
|
|
216
|
-
if (typeof value !== "object" || value === null) return false
|
|
217
|
-
if (!("command" in value) || !Array.isArray(value.command) || !value.command.every((item) => typeof item === "string")) return false
|
|
218
|
-
if (!("environment" in value) || typeof value.environment !== "object" || value.environment === null || Array.isArray(value.environment)) return false
|
|
219
|
-
return Object.values(value.environment).every((item) => typeof item === "string")
|
|
220
|
-
}
|
|
221
|
-
|
|
222
179
|
async function prepareDev(cwd: string, directory: string) {
|
|
223
180
|
const root = resolve(directory)
|
|
224
181
|
const entrypoint = join(root, "packages", "cli", "src", "index.ts")
|
|
225
|
-
if (!(await Bun.file(entrypoint).exists()))
|
|
226
|
-
|
|
182
|
+
if (!(await Bun.file(entrypoint).exists()))
|
|
183
|
+
throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
|
|
184
|
+
const solidPackage = join(
|
|
185
|
+
root,
|
|
186
|
+
"packages",
|
|
187
|
+
"tui",
|
|
188
|
+
"node_modules",
|
|
189
|
+
"@opentui",
|
|
190
|
+
"solid",
|
|
191
|
+
"package.json",
|
|
192
|
+
)
|
|
227
193
|
if (!(await Bun.file(solidPackage).exists())) {
|
|
228
|
-
throw new Error(
|
|
194
|
+
throw new Error(
|
|
195
|
+
`OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`,
|
|
196
|
+
)
|
|
229
197
|
}
|
|
230
198
|
const info: unknown = await Bun.file(solidPackage).json()
|
|
231
|
-
if (!isPackageInfo(info))
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
199
|
+
if (!isPackageInfo(info))
|
|
200
|
+
throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
|
|
201
|
+
await Bun.write(
|
|
202
|
+
join(cwd, "package.json"),
|
|
203
|
+
`${JSON.stringify(
|
|
204
|
+
{
|
|
205
|
+
private: true,
|
|
206
|
+
dependencies: { "@opentui/solid": info.version },
|
|
207
|
+
},
|
|
208
|
+
undefined,
|
|
209
|
+
2,
|
|
210
|
+
)}\n`,
|
|
211
|
+
)
|
|
236
212
|
const install = Bun.spawn([process.execPath, "install"], {
|
|
237
213
|
cwd,
|
|
238
214
|
stdin: "ignore",
|
|
239
|
-
stdout: "
|
|
240
|
-
stderr: "
|
|
215
|
+
stdout: "ignore",
|
|
216
|
+
stderr: "ignore",
|
|
241
217
|
})
|
|
242
218
|
const status = await install.exited
|
|
243
|
-
if (status !== 0)
|
|
219
|
+
if (status !== 0)
|
|
220
|
+
throw new Error(`bun install failed in ${cwd} with status ${status}`)
|
|
244
221
|
return [
|
|
245
222
|
process.execPath,
|
|
246
223
|
"--conditions=browser",
|
|
@@ -249,27 +226,89 @@ async function prepareDev(cwd: string, directory: string) {
|
|
|
249
226
|
]
|
|
250
227
|
}
|
|
251
228
|
|
|
252
|
-
function isPackageInfo(value: unknown): value is { readonly version: string } {
|
|
253
|
-
return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string"
|
|
254
|
-
}
|
|
255
|
-
|
|
256
229
|
async function freePort() {
|
|
257
|
-
const server = Bun.serve({
|
|
230
|
+
const server = Bun.serve({
|
|
231
|
+
hostname: "127.0.0.1",
|
|
232
|
+
port: 0,
|
|
233
|
+
fetch: () => new Response(),
|
|
234
|
+
})
|
|
258
235
|
const port = server.port
|
|
259
236
|
await server.stop(true)
|
|
260
237
|
return port
|
|
261
238
|
}
|
|
262
239
|
|
|
263
|
-
async function
|
|
240
|
+
async function stopService(state: string) {
|
|
241
|
+
const files = [
|
|
242
|
+
join(state, "opencode", "service-local.json"),
|
|
243
|
+
join(state, "opencode", "service.json"),
|
|
244
|
+
]
|
|
245
|
+
const info = await Promise.all(
|
|
246
|
+
files.map((file) =>
|
|
247
|
+
Bun.file(file)
|
|
248
|
+
.json()
|
|
249
|
+
.catch(() => undefined),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
await Promise.all(
|
|
253
|
+
info.map(async (value) => {
|
|
254
|
+
if (!isServiceInfo(value)) return
|
|
255
|
+
try {
|
|
256
|
+
process.kill(value.pid, "SIGTERM")
|
|
257
|
+
} catch {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
const deadline = Date.now() + 1_000
|
|
261
|
+
while (Date.now() < deadline && alive(value.pid)) await Bun.sleep(25)
|
|
262
|
+
if (alive(value.pid)) process.kill(value.pid, "SIGKILL")
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isServiceInfo(value: unknown): value is { readonly pid: number } {
|
|
268
|
+
return (
|
|
269
|
+
typeof value === "object" &&
|
|
270
|
+
value !== null &&
|
|
271
|
+
"pid" in value &&
|
|
272
|
+
typeof value.pid === "number"
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function alive(pid: number) {
|
|
277
|
+
try {
|
|
278
|
+
process.kill(pid, 0)
|
|
279
|
+
return true
|
|
280
|
+
} catch {
|
|
281
|
+
return false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isPackageInfo(value: unknown): value is { readonly version: string } {
|
|
286
|
+
return (
|
|
287
|
+
typeof value === "object" &&
|
|
288
|
+
value !== null &&
|
|
289
|
+
"version" in value &&
|
|
290
|
+
typeof value.version === "string"
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function waitForWebSocket(
|
|
295
|
+
url: string,
|
|
296
|
+
exited: Promise<number>,
|
|
297
|
+
timeout: number,
|
|
298
|
+
) {
|
|
264
299
|
const deadline = Date.now() + timeout
|
|
265
300
|
while (Date.now() < deadline) {
|
|
266
301
|
const connected = await Promise.race([
|
|
267
|
-
open(url)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
302
|
+
open(url)
|
|
303
|
+
.then((socket) => {
|
|
304
|
+
socket.close()
|
|
305
|
+
return true
|
|
306
|
+
})
|
|
307
|
+
.catch(() => false),
|
|
271
308
|
exited.then((code) => {
|
|
272
|
-
throw new Error(
|
|
309
|
+
throw new Error(
|
|
310
|
+
`OpenCode exited with status ${code} before ${url} became ready`,
|
|
311
|
+
)
|
|
273
312
|
}),
|
|
274
313
|
])
|
|
275
314
|
if (connected) return
|
|
@@ -281,15 +320,21 @@ async function waitForWebSocket(url: string, exited: Promise<number>, timeout: n
|
|
|
281
320
|
function open(url: string) {
|
|
282
321
|
return new Promise<WebSocket>((resolveSocket, reject) => {
|
|
283
322
|
const socket = new WebSocket(url)
|
|
284
|
-
socket.addEventListener("open", () => resolveSocket(socket), {
|
|
285
|
-
|
|
323
|
+
socket.addEventListener("open", () => resolveSocket(socket), {
|
|
324
|
+
once: true,
|
|
325
|
+
})
|
|
326
|
+
socket.addEventListener(
|
|
327
|
+
"error",
|
|
328
|
+
() => reject(new Error(`cannot connect to ${url}`)),
|
|
329
|
+
{ once: true },
|
|
330
|
+
)
|
|
286
331
|
})
|
|
287
332
|
}
|
|
288
333
|
|
|
289
|
-
function randomSuffix() {
|
|
290
|
-
return crypto.randomUUID().slice(0, 6)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
334
|
function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
|
|
294
|
-
return Object.fromEntries(
|
|
335
|
+
return Object.fromEntries(
|
|
336
|
+
Object.entries(env).filter(
|
|
337
|
+
(entry): entry is [string, string] => entry[1] !== undefined,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
295
340
|
}
|
|
@@ -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
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -1,92 +1,54 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
1
2
|
import { homedir } from "node:os"
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
await rm(
|
|
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
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
await
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
}
|