opencode-drive 0.1.0
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 +90 -0
- package/bin/opencode-drive +2 -0
- package/package.json +45 -0
- package/src/cli/commands.ts +175 -0
- package/src/cli/describe.ts +14 -0
- package/src/cli/driver-runner.ts +39 -0
- package/src/cli/driver.ts +28 -0
- package/src/cli/index.ts +158 -0
- package/src/cli/instance.ts +218 -0
- package/src/cli/parse.ts +24 -0
- package/src/cli/registry.ts +120 -0
- package/src/cli/send.ts +48 -0
- package/src/cli/start.ts +56 -0
- package/src/cli/types.ts +46 -0
- package/src/client/backend.ts +184 -0
- package/src/client/client.ts +252 -0
- package/src/client/index.ts +17 -0
- package/src/client/protocol.ts +186 -0
- package/src/experimental/campaign-api.ts +34 -0
- package/src/experimental/campaign.ts +144 -0
- package/src/experimental/cli-campaign.ts +179 -0
- package/src/experimental/drive.ts +37 -0
- package/src/experimental/driver.ts +41 -0
- package/src/experimental/flow-driver.ts +189 -0
- package/src/experimental/flows/generate.ts +278 -0
- package/src/experimental/flows/index.ts +6 -0
- package/src/experimental/flows/properties.ts +51 -0
- package/src/experimental/flows/types.ts +47 -0
- package/src/experimental/flows/weights.ts +198 -0
- package/src/experimental/generators/config.ts +240 -0
- package/src/experimental/generators/filesystem.ts +95 -0
- package/src/experimental/generators/generate.ts +32 -0
- package/src/experimental/generators/index.ts +10 -0
- package/src/experimental/generators/initial-state.ts +37 -0
- package/src/experimental/generators/random.ts +35 -0
- package/src/experimental/hello-driver.ts +42 -0
- package/src/experimental/index.ts +3 -0
- package/src/experimental/model/derive.ts +119 -0
- package/src/experimental/model/index.ts +2 -0
- package/src/experimental/model/model.ts +42 -0
- package/src/experimental/opencode-simulation.ts +1 -0
- package/src/experimental/reproduce-stale-running-visible.ts +60 -0
- package/src/experimental/reproduce-stale-running.ts +26 -0
- package/src/experimental/stale-running-driver.ts +74 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join, resolve } from "node:path"
|
|
4
|
+
import { registerInstance, registryDirectory, transferInstance, unregisterInstance } from "./registry.js"
|
|
5
|
+
import type { InstanceManifest } from "./types.js"
|
|
6
|
+
|
|
7
|
+
export interface LaunchOptions {
|
|
8
|
+
readonly name?: string
|
|
9
|
+
readonly command?: ReadonlyArray<string>
|
|
10
|
+
readonly dev?: string
|
|
11
|
+
readonly state?: string
|
|
12
|
+
readonly visible?: boolean
|
|
13
|
+
readonly env?: Readonly<Record<string, string>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function launchInstance(options: LaunchOptions = {}) {
|
|
17
|
+
const name = options.name ?? "default"
|
|
18
|
+
const artifacts = resolve(join(tmpdir(), "opencode-drive", `${name}-${randomSuffix()}`))
|
|
19
|
+
const cwd = artifacts
|
|
20
|
+
const [uiPort, backendPort] = await Promise.all([freePort(), freePort()])
|
|
21
|
+
const endpoints = {
|
|
22
|
+
ui: `ws://127.0.0.1:${uiPort}`,
|
|
23
|
+
backend: `ws://127.0.0.1:${backendPort}`,
|
|
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,
|
|
35
|
+
}
|
|
36
|
+
await Promise.all([
|
|
37
|
+
mkdir(artifacts, { recursive: true }),
|
|
38
|
+
mkdir(cwd, { recursive: true }),
|
|
39
|
+
mkdir(join(artifacts, "home", ".cache"), { recursive: true }),
|
|
40
|
+
mkdir(join(artifacts, "home", ".config"), { recursive: true }),
|
|
41
|
+
mkdir(join(artifacts, "home", ".local", "share"), { recursive: true }),
|
|
42
|
+
mkdir(join(artifacts, "home", ".local", "state"), { recursive: true }),
|
|
43
|
+
])
|
|
44
|
+
const state = options.state ? resolve(options.state) : join(artifacts, "state")
|
|
45
|
+
if (!options.state) {
|
|
46
|
+
await rm(state, { recursive: true, force: true })
|
|
47
|
+
await Promise.all([
|
|
48
|
+
mkdir(join(state, "files", ".git"), { recursive: true }),
|
|
49
|
+
mkdir(join(state, "files", ".opencode"), { recursive: true }),
|
|
50
|
+
])
|
|
51
|
+
await Bun.write(
|
|
52
|
+
join(state, "files", ".opencode", "opencode.jsonc"),
|
|
53
|
+
`${JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
model: "simulation/sim-model",
|
|
56
|
+
permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
57
|
+
providers: {
|
|
58
|
+
simulation: {
|
|
59
|
+
name: "Simulation",
|
|
60
|
+
request: { body: { apiKey: "sim-key" } },
|
|
61
|
+
models: {
|
|
62
|
+
"sim-model": {
|
|
63
|
+
name: "Simulated Model",
|
|
64
|
+
api: {
|
|
65
|
+
type: "aisdk",
|
|
66
|
+
package: "@ai-sdk/openai-compatible",
|
|
67
|
+
url: "https://api.openai.com/v1",
|
|
68
|
+
},
|
|
69
|
+
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
70
|
+
limit: { context: 128000, output: 16000 },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
undefined,
|
|
77
|
+
2,
|
|
78
|
+
)}\n`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
await registerInstance(manifest)
|
|
82
|
+
const environment = cleanEnv({
|
|
83
|
+
...process.env,
|
|
84
|
+
...options.env,
|
|
85
|
+
DRIVE_REGISTRY_DIR: registryDirectory(),
|
|
86
|
+
OPENCODE_SIMULATE: "1",
|
|
87
|
+
OPENCODE_SIMULATE_STATE: state,
|
|
88
|
+
OPENCODE_DRIVE: name,
|
|
89
|
+
OPENCODE_DRIVE_RENDERER: options.visible ? "visible" : "headless",
|
|
90
|
+
OPENCODE_CONFIG_DIR: join(cwd, ".opencode"),
|
|
91
|
+
OPENCODE_DB: ":memory:",
|
|
92
|
+
XDG_CACHE_HOME: join(artifacts, "home", ".cache"),
|
|
93
|
+
XDG_CONFIG_HOME: join(artifacts, "home", ".config"),
|
|
94
|
+
XDG_DATA_HOME: join(artifacts, "home", ".local", "share"),
|
|
95
|
+
XDG_STATE_HOME: join(artifacts, "home", ".local", "state"),
|
|
96
|
+
})
|
|
97
|
+
const command = options.dev
|
|
98
|
+
? await prepareDev(cwd, options.dev)
|
|
99
|
+
: options.command?.length
|
|
100
|
+
? [...options.command]
|
|
101
|
+
: ["opencode2", "--standalone"]
|
|
102
|
+
const visible = options.visible === true
|
|
103
|
+
const child = Bun.spawn(command, {
|
|
104
|
+
cwd,
|
|
105
|
+
env: environment,
|
|
106
|
+
stdin: visible ? "inherit" : "ignore",
|
|
107
|
+
stdout: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stdout.log")),
|
|
108
|
+
stderr: visible ? "inherit" : Bun.file(join(artifacts, "opencode.stderr.log")),
|
|
109
|
+
})
|
|
110
|
+
const lifecycle = { detached: false, stopping: undefined as Promise<void> | undefined }
|
|
111
|
+
return {
|
|
112
|
+
manifest,
|
|
113
|
+
child,
|
|
114
|
+
async waitForDrive(requirement: "ui" | "backend" | "both" = "both", timeout = 60_000) {
|
|
115
|
+
const urls = requirement === "both"
|
|
116
|
+
? [endpoints.ui, endpoints.backend]
|
|
117
|
+
: [requirement === "ui" ? endpoints.ui : endpoints.backend]
|
|
118
|
+
await Promise.all(urls.map((url) => waitForWebSocket(url, child.exited, timeout)))
|
|
119
|
+
},
|
|
120
|
+
async detach() {
|
|
121
|
+
await transferInstance(manifest, child.pid)
|
|
122
|
+
child.unref()
|
|
123
|
+
lifecycle.detached = true
|
|
124
|
+
},
|
|
125
|
+
stop(force = false) {
|
|
126
|
+
if (lifecycle.detached) return Promise.resolve()
|
|
127
|
+
if (lifecycle.stopping) return lifecycle.stopping
|
|
128
|
+
lifecycle.stopping = (async () => {
|
|
129
|
+
if (force) {
|
|
130
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
131
|
+
} else if (child.exitCode === null) {
|
|
132
|
+
child.kill("SIGTERM")
|
|
133
|
+
await Promise.race([child.exited, Bun.sleep(1_000)])
|
|
134
|
+
}
|
|
135
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
136
|
+
await child.exited
|
|
137
|
+
await unregisterInstance(name, process.pid)
|
|
138
|
+
})()
|
|
139
|
+
return lifecycle.stopping
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function prepareDev(cwd: string, directory: string) {
|
|
145
|
+
const root = resolve(directory)
|
|
146
|
+
const entrypoint = join(root, "packages", "cli", "src", "index.ts")
|
|
147
|
+
if (!(await Bun.file(entrypoint).exists())) throw new Error(`OpenCode development entrypoint not found: ${entrypoint}`)
|
|
148
|
+
const solidPackage = join(root, "packages", "tui", "node_modules", "@opentui", "solid", "package.json")
|
|
149
|
+
if (!(await Bun.file(solidPackage).exists())) {
|
|
150
|
+
throw new Error(`OpenCode development dependency not found: ${solidPackage}; run bun install in ${root}`)
|
|
151
|
+
}
|
|
152
|
+
const info: unknown = await Bun.file(solidPackage).json()
|
|
153
|
+
if (!isPackageInfo(info)) throw new Error(`Invalid @opentui/solid package metadata: ${solidPackage}`)
|
|
154
|
+
await Bun.write(join(cwd, "package.json"), `${JSON.stringify({
|
|
155
|
+
private: true,
|
|
156
|
+
dependencies: { "@opentui/solid": info.version },
|
|
157
|
+
}, undefined, 2)}\n`)
|
|
158
|
+
const install = Bun.spawn([process.execPath, "install"], {
|
|
159
|
+
cwd,
|
|
160
|
+
stdin: "ignore",
|
|
161
|
+
stdout: "inherit",
|
|
162
|
+
stderr: "inherit",
|
|
163
|
+
})
|
|
164
|
+
const status = await install.exited
|
|
165
|
+
if (status !== 0) throw new Error(`bun install failed in ${cwd} with status ${status}`)
|
|
166
|
+
return [
|
|
167
|
+
process.execPath,
|
|
168
|
+
"--conditions=browser",
|
|
169
|
+
"--preload=@opentui/solid/preload",
|
|
170
|
+
entrypoint,
|
|
171
|
+
"--standalone",
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPackageInfo(value: unknown): value is { readonly version: string } {
|
|
176
|
+
return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function freePort() {
|
|
180
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response() })
|
|
181
|
+
const port = server.port
|
|
182
|
+
await server.stop(true)
|
|
183
|
+
return port
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function waitForWebSocket(url: string, exited: Promise<number>, timeout: number) {
|
|
187
|
+
const deadline = Date.now() + timeout
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
const connected = await Promise.race([
|
|
190
|
+
open(url).then((socket) => {
|
|
191
|
+
socket.close()
|
|
192
|
+
return true
|
|
193
|
+
}).catch(() => false),
|
|
194
|
+
exited.then((code) => {
|
|
195
|
+
throw new Error(`OpenCode exited with status ${code} before ${url} became ready`)
|
|
196
|
+
}),
|
|
197
|
+
])
|
|
198
|
+
if (connected) return
|
|
199
|
+
await Bun.sleep(50)
|
|
200
|
+
}
|
|
201
|
+
throw new Error(`timed out waiting for drive endpoint ${url}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function open(url: string) {
|
|
205
|
+
return new Promise<WebSocket>((resolveSocket, reject) => {
|
|
206
|
+
const socket = new WebSocket(url)
|
|
207
|
+
socket.addEventListener("open", () => resolveSocket(socket), { once: true })
|
|
208
|
+
socket.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), { once: true })
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function randomSuffix() {
|
|
213
|
+
return crypto.randomUUID().slice(0, 6)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cleanEnv(env: Readonly<Record<string, string | undefined>>) {
|
|
217
|
+
return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined))
|
|
218
|
+
}
|
package/src/cli/parse.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { commandAcceptsValue } from "./commands.js"
|
|
2
|
+
import type { DriveCommand } from "./types.js"
|
|
3
|
+
|
|
4
|
+
export function extractCommands(args: ReadonlyArray<string>) {
|
|
5
|
+
const commands: DriveCommand[] = []
|
|
6
|
+
const remaining: string[] = []
|
|
7
|
+
const separator = args.indexOf("--")
|
|
8
|
+
const cli = separator === -1 ? args : args.slice(0, separator)
|
|
9
|
+
const app = separator === -1 ? [] : args.slice(separator + 1)
|
|
10
|
+
|
|
11
|
+
for (let index = 0; index < cli.length; index++) {
|
|
12
|
+
const flag = cli[index]!
|
|
13
|
+
if (!flag.startsWith("--command.")) {
|
|
14
|
+
remaining.push(flag)
|
|
15
|
+
continue
|
|
16
|
+
}
|
|
17
|
+
const operation = flag.slice("--command.".length)
|
|
18
|
+
const acceptsValue = commandAcceptsValue(operation)
|
|
19
|
+
const value = acceptsValue ? cli[++index] : undefined
|
|
20
|
+
if (acceptsValue && (value === undefined || value.startsWith("--"))) throw new Error(`${flag} requires a value`)
|
|
21
|
+
commands.push({ operation, ...(value === undefined ? {} : { value }) })
|
|
22
|
+
}
|
|
23
|
+
return { args: remaining, app, commands }
|
|
24
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
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"
|
|
5
|
+
|
|
6
|
+
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")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function manifestPath(name: string) {
|
|
12
|
+
return join(registryDirectory(), `${validateName(name)}.json`)
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
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)
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
|
59
|
+
}
|
|
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(", ")})`)
|
|
63
|
+
}
|
|
64
|
+
|
|
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))
|
|
78
|
+
}
|
|
79
|
+
|
|
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
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isManifest(value: unknown): value is InstanceManifest {
|
|
93
|
+
if (typeof value !== "object" || value === null) return false
|
|
94
|
+
if (!("version" in value) || value.version !== 1) return false
|
|
95
|
+
if (!("name" in value) || typeof value.name !== "string") return false
|
|
96
|
+
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
|
+
if (!("artifacts" in value) || typeof value.artifacts !== "string") return false
|
|
102
|
+
if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
|
|
103
|
+
return "ui" in value.endpoints && typeof value.endpoints.ui === "string" && "backend" in value.endpoints && typeof value.endpoints.backend === "string"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function processAlive(pid: number) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(pid, 0)
|
|
109
|
+
return true
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
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
|
+
}
|
package/src/cli/send.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { tmpdir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import { defaultBackendPort, defaultPort } from "../client/index.js"
|
|
4
|
+
import { executeCommands } from "./commands.js"
|
|
5
|
+
import { runDriver } from "./driver.js"
|
|
6
|
+
import { resolveInstance } from "./registry.js"
|
|
7
|
+
import type { SendOptions } from "./types.js"
|
|
8
|
+
|
|
9
|
+
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")
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (options.driver) {
|
|
27
|
+
await runDriver(resolve(options.driver), manifest)
|
|
28
|
+
return
|
|
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
|
+
}
|
|
48
|
+
}
|
package/src/cli/start.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import { launchInstance } from "./instance.js"
|
|
6
|
+
import type { StartOptions } from "./types.js"
|
|
7
|
+
|
|
8
|
+
export async function start(options: StartOptions) {
|
|
9
|
+
if (options.campaign) return runCampaign(options)
|
|
10
|
+
const instance = await launchInstance({
|
|
11
|
+
name: options.name,
|
|
12
|
+
command: options.command,
|
|
13
|
+
dev: options.dev,
|
|
14
|
+
state: options.state,
|
|
15
|
+
visible: options.visible,
|
|
16
|
+
})
|
|
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
|
+
const interrupt = () => void instance.stop()
|
|
26
|
+
process.once("SIGINT", interrupt)
|
|
27
|
+
process.once("SIGTERM", interrupt)
|
|
28
|
+
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")
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (options.driver) {
|
|
45
|
+
await instance.waitForDrive("both")
|
|
46
|
+
await runDriver(resolve(options.driver), instance.manifest)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const status = await instance.child.exited
|
|
50
|
+
if (status !== 0) process.exitCode = status
|
|
51
|
+
} finally {
|
|
52
|
+
process.off("SIGINT", interrupt)
|
|
53
|
+
process.off("SIGTERM", interrupt)
|
|
54
|
+
await instance.stop()
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/cli/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
export interface DriveCommand {
|
|
17
|
+
readonly operation: string
|
|
18
|
+
readonly value?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
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 {
|
|
28
|
+
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
|
|
35
|
+
readonly visible: boolean
|
|
36
|
+
readonly dev?: string
|
|
37
|
+
readonly state?: string
|
|
38
|
+
readonly anchor?: string
|
|
39
|
+
readonly command: ReadonlyArray<string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SendOptions extends CommonOptions {
|
|
43
|
+
readonly kind: "send"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type CliOptions = StartOptions | SendOptions
|