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.
@@ -1,19 +1,29 @@
1
- import { mkdir, rm } from "node:fs/promises"
1
+ import { mkdir, open, readdir, rename, rm } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
- import { join } from "node:path"
3
+ import { basename, join } from "node:path"
4
4
 
5
5
  export interface InstanceManifest {
6
6
  readonly version: 1
7
7
  readonly name: string
8
8
  readonly pid: number
9
+ readonly startedAt: string
10
+ readonly cwd: string
9
11
  readonly artifacts: string
10
12
  readonly visible: boolean
13
+ readonly status: "starting" | "ready"
11
14
  readonly endpoints: { readonly ui: string; readonly backend: string }
12
15
  readonly control: string
13
16
  }
14
17
 
15
18
  export function registryDirectory() {
16
- return process.env.DRIVE_REGISTRY_DIR ?? join(homedir(), ".local", "state", "opencode-drive", "instances")
19
+ return (
20
+ process.env.DRIVE_REGISTRY_DIR ??
21
+ join(
22
+ process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
23
+ "opencode-drive",
24
+ "instances",
25
+ )
26
+ )
17
27
  }
18
28
 
19
29
  export function manifestPath(name: string) {
@@ -25,30 +35,185 @@ export function controlPath(name: string) {
25
35
  }
26
36
 
27
37
  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`)
38
+ await withLock(manifest.name, false, async () => {
39
+ const existing = await read(manifestPath(manifest.name))
40
+ if (existing && alive(existing.pid))
41
+ throw new Error(`drive instance "${manifest.name}" is already running`)
42
+ await Promise.all([
43
+ rm(manifestPath(manifest.name), { force: true }),
44
+ rm(controlPath(manifest.name), { force: true }),
45
+ ])
46
+ await write(manifest)
47
+ })
48
+ }
49
+
50
+ export async function markReady(name: string, pid: number) {
51
+ await markStatus(name, pid, "ready")
52
+ }
53
+
54
+ export async function markStarting(name: string, pid: number) {
55
+ await markStatus(name, pid, "starting")
56
+ }
57
+
58
+ async function markStatus(
59
+ name: string,
60
+ pid: number,
61
+ status: InstanceManifest["status"],
62
+ ) {
63
+ await withLock(name, true, async () => {
64
+ const manifest = await read(manifestPath(name))
65
+ if (!manifest || manifest.pid !== pid)
66
+ throw new Error(`drive instance "${name}" changed ownership`)
67
+ await write({ ...manifest, status })
68
+ })
33
69
  }
34
70
 
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`)
71
+ export async function resolveInstance(
72
+ name?: string,
73
+ options: { readonly ready?: boolean } = {},
74
+ ) {
75
+ const instances = await listInstances()
76
+ const manifest = name
77
+ ? instances.find((item) => item.name === name)
78
+ : instances.length === 1
79
+ ? instances[0]
80
+ : undefined
81
+ if (!manifest) {
82
+ if (!name && instances.length > 1)
83
+ throw new Error(
84
+ `multiple drive instances are running; pass --name (${instances.map((item) => item.name).join(", ")})`,
85
+ )
86
+ throw new Error(
87
+ name
88
+ ? `drive instance "${name}" was not found`
89
+ : "no drive instances are running",
90
+ )
40
91
  }
92
+ if (options.ready !== false && manifest.status !== "ready")
93
+ throw new Error(`drive instance "${manifest.name}" is still starting`)
41
94
  return manifest
42
95
  }
43
96
 
44
- export async function unregister(name: string) {
45
- await Promise.all([rm(manifestPath(name), { force: true }), rm(controlPath(name), { force: true })])
97
+ export async function listInstances() {
98
+ await mkdir(registryDirectory(), { recursive: true })
99
+ const files = await readdir(registryDirectory())
100
+ const manifests = await Promise.all(
101
+ files
102
+ .filter((file) => file.endsWith(".json"))
103
+ .map(async (file) => {
104
+ const name = basename(file, ".json")
105
+ if (!validName(name)) {
106
+ await rm(join(registryDirectory(), file), { force: true })
107
+ return undefined
108
+ }
109
+ const manifest = await read(join(registryDirectory(), file))
110
+ if (manifest?.name === name && alive(manifest.pid)) return manifest
111
+ await prune(name, manifest?.pid)
112
+ return undefined
113
+ }),
114
+ )
115
+ const active = manifests.filter(
116
+ (manifest): manifest is InstanceManifest => manifest !== undefined,
117
+ )
118
+ const names = new Set(active.map((manifest) => manifest.name))
119
+ await Promise.all(
120
+ files
121
+ .filter((file) => file.endsWith(".sock"))
122
+ .flatMap((file) => {
123
+ const name = basename(file, ".sock")
124
+ if (!validName(name))
125
+ return [rm(join(registryDirectory(), file), { force: true })]
126
+ if (!names.has(name)) return [prune(name)]
127
+ return []
128
+ }),
129
+ )
130
+ return active.sort((a, b) => a.name.localeCompare(b.name))
131
+ }
132
+
133
+ export async function unregister(name: string, pid: number) {
134
+ await withLock(name, true, async () => {
135
+ const manifest = await read(manifestPath(name))
136
+ if (manifest?.pid !== pid) return
137
+ await Promise.all([
138
+ rm(manifestPath(name), { force: true }),
139
+ rm(controlPath(name), { force: true }),
140
+ ])
141
+ })
142
+ }
143
+
144
+ async function prune(name: string, pid?: number) {
145
+ await withLock(name, true, async () => {
146
+ const manifest = await read(manifestPath(name))
147
+ if (manifest && (manifest.pid !== pid || alive(manifest.pid))) return
148
+ await Promise.all([
149
+ rm(manifestPath(name), { force: true }),
150
+ rm(controlPath(name), { force: true }),
151
+ ])
152
+ })
153
+ }
154
+
155
+ async function write(manifest: InstanceManifest) {
156
+ const file = manifestPath(manifest.name)
157
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
158
+ try {
159
+ await Bun.write(temporary, `${JSON.stringify(manifest, undefined, 2)}\n`)
160
+ await rename(temporary, file)
161
+ } finally {
162
+ await rm(temporary, { force: true })
163
+ }
164
+ }
165
+
166
+ async function read(file: string): Promise<InstanceManifest | undefined> {
167
+ const value: unknown = await Bun.file(file)
168
+ .json()
169
+ .catch(() => undefined)
170
+ if (isManifest(value)) return value
171
+ return undefined
172
+ }
173
+
174
+ async function withLock(
175
+ name: string,
176
+ wait: boolean,
177
+ task: () => Promise<void>,
178
+ ) {
179
+ await mkdir(registryDirectory(), { recursive: true })
180
+ const lock = `${manifestPath(name)}.lock`
181
+ const deadline = Date.now() + 10_000
182
+ while (true) {
183
+ const handle = await open(lock, "wx").catch((error: unknown) => {
184
+ if (isNodeError(error) && error.code === "EEXIST") return undefined
185
+ throw error
186
+ })
187
+ if (handle) {
188
+ try {
189
+ await handle.writeFile(`${process.pid}\n`)
190
+ await task()
191
+ return
192
+ } finally {
193
+ await handle.close()
194
+ await rm(lock, { force: true })
195
+ }
196
+ }
197
+ if (await staleLock(lock)) {
198
+ await rm(lock, { force: true })
199
+ continue
200
+ }
201
+ if (!wait)
202
+ throw new Error(`drive instance "${name}" is already starting`)
203
+ if (Date.now() >= deadline)
204
+ throw new Error(`timed out updating drive instance "${name}"`)
205
+ await Bun.sleep(10)
206
+ }
46
207
  }
47
208
 
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
209
+ async function staleLock(file: string) {
210
+ const pid = Number.parseInt(
211
+ await Bun.file(file)
212
+ .text()
213
+ .catch(() => ""),
214
+ 10,
215
+ )
216
+ return Number.isInteger(pid) && !alive(pid)
52
217
  }
53
218
 
54
219
  function isManifest(value: unknown): value is InstanceManifest {
@@ -56,18 +221,44 @@ function isManifest(value: unknown): value is InstanceManifest {
56
221
  if (!("version" in value) || value.version !== 1) return false
57
222
  if (!("name" in value) || typeof value.name !== "string") return false
58
223
  if (!("pid" in value) || typeof value.pid !== "number") return false
59
- if (!("artifacts" in value) || typeof value.artifacts !== "string") return false
224
+ if (!("startedAt" in value) || typeof value.startedAt !== "string")
225
+ return false
226
+ if (!("cwd" in value) || typeof value.cwd !== "string") return false
227
+ if (!("artifacts" in value) || typeof value.artifacts !== "string")
228
+ return false
60
229
  if (!("visible" in value) || typeof value.visible !== "boolean") return false
230
+ if (
231
+ !("status" in value) ||
232
+ (value.status !== "starting" && value.status !== "ready")
233
+ )
234
+ return false
61
235
  if (!("control" in value) || typeof value.control !== "string") return false
62
- if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
63
- return "ui" in value.endpoints && typeof value.endpoints.ui === "string" && "backend" in value.endpoints && typeof value.endpoints.backend === "string"
236
+ if (
237
+ !("endpoints" in value) ||
238
+ typeof value.endpoints !== "object" ||
239
+ value.endpoints === null
240
+ )
241
+ return false
242
+ return (
243
+ "ui" in value.endpoints &&
244
+ typeof value.endpoints.ui === "string" &&
245
+ "backend" in value.endpoints &&
246
+ typeof value.endpoints.backend === "string"
247
+ )
64
248
  }
65
249
 
66
250
  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}"`)
251
+ if (!validName(name))
252
+ throw new Error(
253
+ "instance names must contain 1-64 letters, numbers, dots, underscores, or dashes",
254
+ )
68
255
  return name
69
256
  }
70
257
 
258
+ function validName(name: string) {
259
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)
260
+ }
261
+
71
262
  function alive(pid: number) {
72
263
  try {
73
264
  process.kill(pid, 0)
@@ -76,3 +267,7 @@ function alive(pid: number) {
76
267
  return false
77
268
  }
78
269
  }
270
+
271
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
272
+ return error instanceof Error && "code" in error
273
+ }
@@ -0,0 +1,334 @@
1
+ import { Backend } from "../client/protocol.js"
2
+
3
+ export const responseTypes = ["text", "reasoning", "tool", "diff"] as const
4
+ export type ResponseType = (typeof responseTypes)[number]
5
+
6
+ export interface ResponseConfiguration {
7
+ readonly types: ReadonlyArray<ResponseType>
8
+ readonly tools: ReadonlyArray<string>
9
+ }
10
+
11
+ export interface ResponseUpdate {
12
+ readonly types?: ReadonlyArray<string>
13
+ readonly tools?: ReadonlyArray<string>
14
+ }
15
+
16
+ type JsonValue =
17
+ | null
18
+ | boolean
19
+ | number
20
+ | string
21
+ | ReadonlyArray<JsonValue>
22
+ | { readonly [key: string]: JsonValue }
23
+
24
+ const textResponses = [
25
+ "I took a careful look at the problem and followed it through the parts of the system that actually shape the behavior. The result is simpler than it first appeared: one clear boundary, one owner, and fewer opportunities for state to drift. There is a quiet satisfaction in watching the pieces settle into place.",
26
+ "The important path is working now, and the surrounding behavior remains intact. I kept the change focused, made the failure case visible, and checked the point where control passes from one process to another. It is a small adjustment, but it lets a little more daylight into the design.",
27
+ "I traced the request from its first input to its final effect and found the useful seam in between. The implementation now says what it means without asking the reader to remember hidden state. Nothing dramatic happened, which is often the nicest possible ending for this kind of work.",
28
+ "The pieces fit together cleanly after the change. Inputs are handled where they arrive, ownership stays explicit, and cleanup follows the same path every time. The code feels calmer now, like a room after someone has opened a window and put the books back in order.",
29
+ "I checked the current behavior, made the narrow change, and followed it through the edge cases that mattered. The result is direct enough to explain and ordinary enough to trust. Somewhere in the background, the event loop continues its patient little orbit.",
30
+ ]
31
+
32
+ const reasoningResponses = [
33
+ "I should inspect the available context before choosing the smallest reliable path through this.",
34
+ "I need to preserve the working behavior while checking the boundary where ownership changes hands.",
35
+ "The request contains enough information to proceed, though the assumptions deserve one careful pass first.",
36
+ "I will separate the observed behavior from the implementation detail, then test the seam between them.",
37
+ "The safest approach is to validate the current state, make one deliberate change, and follow its effects.",
38
+ ]
39
+
40
+ export function createResponseSettings() {
41
+ let configuration: ResponseConfiguration = {
42
+ types: ["text", "reasoning", "diff", "tool"],
43
+ tools: ["write", "apply_patch"],
44
+ }
45
+ return {
46
+ current: () => configuration,
47
+ update(input: ResponseUpdate) {
48
+ const updated = {
49
+ types: input.types ? parseTypes(input.types) : configuration.types,
50
+ tools: input.tools ? parseTools(input.tools) : configuration.tools,
51
+ }
52
+ if (
53
+ updated.types.includes("diff") &&
54
+ !updated.tools.includes("*") &&
55
+ !updated.tools.some((tool) => diffTools.has(tool))
56
+ )
57
+ throw new Error(
58
+ "diff responses require apply_patch, write, or edit in --tools",
59
+ )
60
+ configuration = updated
61
+ return configuration
62
+ },
63
+ }
64
+ }
65
+
66
+ export function generateResponse(
67
+ configuration: ResponseConfiguration,
68
+ request: Backend.OpenedExchange,
69
+ ): {
70
+ readonly items: ReadonlyArray<Backend.Item>
71
+ readonly finish: Backend.FinishReason
72
+ } {
73
+ if (hasToolResult(request.body)) return textResponse()
74
+ const tools = offeredTools(request.body).filter(
75
+ (tool) => configuration.tools.includes("*") || configuration.tools.includes(tool.name),
76
+ )
77
+ const available = configuration.types.filter(
78
+ (type) =>
79
+ (type !== "tool" || tools.length > 0) &&
80
+ (type !== "diff" || tools.some((tool) => diffTools.has(tool.name))),
81
+ )
82
+ const type = pick(available)
83
+ if (type === "reasoning")
84
+ return {
85
+ items: [
86
+ { type: "reasoningDelta", text: pick(reasoningResponses) },
87
+ { type: "textDelta", text: pick(textResponses) },
88
+ ],
89
+ finish: "stop",
90
+ }
91
+ if (type === "tool") return toolResponse(tools.slice(0, 3), false)
92
+ if (type === "diff")
93
+ return toolResponse(
94
+ [
95
+ ["apply_patch", "write", "edit"]
96
+ .map((name) => tools.find((tool) => tool.name === name))
97
+ .find((tool) => tool !== undefined)!,
98
+ ],
99
+ true,
100
+ )
101
+ if (type === "text") return textResponse()
102
+ return {
103
+ items: [
104
+ {
105
+ type: "textDelta",
106
+ text: "No configured response type matched the tools offered by this request.",
107
+ },
108
+ ],
109
+ finish: "stop",
110
+ }
111
+ }
112
+
113
+ function textResponse() {
114
+ return {
115
+ items: [{ type: "textDelta" as const, text: pick(textResponses) }],
116
+ finish: "stop" as const,
117
+ }
118
+ }
119
+
120
+ interface ToolDefinition {
121
+ readonly name: string
122
+ readonly parameters: unknown
123
+ }
124
+
125
+ const diffTools = new Set(["apply_patch", "edit", "write"])
126
+ let gardenExpanded = false
127
+
128
+ function toolResponse(tools: ReadonlyArray<ToolDefinition>, diff: boolean) {
129
+ return {
130
+ items: tools.map((tool, index) => ({
131
+ type: "toolCall" as const,
132
+ index,
133
+ id: `call_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`,
134
+ name: tool.name,
135
+ input: toolInput(tool, diff),
136
+ })),
137
+ finish: "tool-calls" as const,
138
+ }
139
+ }
140
+
141
+ function offeredTools(body: unknown) {
142
+ if (!isRecord(body) || !Array.isArray(body.tools)) return []
143
+ return body.tools.flatMap((value): ToolDefinition[] => {
144
+ if (!isRecord(value)) return []
145
+ const definition = isRecord(value.function) ? value.function : value
146
+ if (typeof definition.name !== "string") return []
147
+ return [
148
+ {
149
+ name: definition.name,
150
+ parameters: definition.parameters ?? definition.inputSchema,
151
+ },
152
+ ]
153
+ })
154
+ }
155
+
156
+ function toolInput(tool: ToolDefinition, diff: boolean) {
157
+ const generated = schemaValue(tool.parameters, "input")
158
+ const input = isJsonRecord(generated) ? generated : {}
159
+ const known = knownInput(tool.name, diff)
160
+ if (!isRecord(tool.parameters) || !isRecord(tool.parameters.properties))
161
+ return { ...input, ...known }
162
+ const properties = tool.parameters.properties
163
+ return {
164
+ ...input,
165
+ ...Object.fromEntries(
166
+ Object.entries(known).filter(
167
+ ([key, value]) =>
168
+ key in properties && acceptsValue(properties[key], value),
169
+ ),
170
+ ),
171
+ }
172
+ }
173
+
174
+ function knownInput(name: string, diff: boolean): Record<string, JsonValue> {
175
+ const suffix = crypto.randomUUID().slice(0, 8)
176
+ if (name === "apply_patch")
177
+ return {
178
+ patchText: gardenPatch(),
179
+ }
180
+ if (name === "write")
181
+ return {
182
+ path: "src/garden.js",
183
+ filePath: "src/garden.js",
184
+ content:
185
+ 'export function greet(name, punctuation = "!") {\n const visitor = name.trim() || "traveler"\n return `Hello, ${visitor}${punctuation}`\n}\n',
186
+ }
187
+ if (name === "edit")
188
+ return {
189
+ path: ".opencode/opencode.jsonc",
190
+ filePath: ".opencode/opencode.jsonc",
191
+ oldString: '"name": "Simulation"',
192
+ newString: `"name": "Simulation ${suffix}"`,
193
+ }
194
+ if (name === "read")
195
+ return { path: ".opencode/opencode.jsonc", filePath: ".opencode/opencode.jsonc", offset: 1, limit: 120 }
196
+ if (name === "glob") return { pattern: "**/*", path: ".", limit: 20 }
197
+ if (name === "grep")
198
+ return { pattern: "simulation", path: ".opencode", include: "*.jsonc", limit: 20 }
199
+ if (name === "shell" || name === "bash")
200
+ return { command: diff ? "git diff --stat" : "pwd", description: "Inspect the workspace" }
201
+ return {}
202
+ }
203
+
204
+ function gardenPatch() {
205
+ const patch = gardenExpanded
206
+ ? '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name, punctuation = "!") {\n- const visitor = name.trim() || "traveler"\n- return `Hello, ${visitor}${punctuation}`\n+export function greet(name) {\n+ return `Hello, ${name}.`\n }\n*** End Patch'
207
+ : '*** Begin Patch\n*** Update File: src/garden.js\n@@\n-export function greet(name) {\n- return `Hello, ${name}.`\n+export function greet(name, punctuation = "!") {\n+ const visitor = name.trim() || "traveler"\n+ return `Hello, ${visitor}${punctuation}`\n }\n*** End Patch'
208
+ gardenExpanded = !gardenExpanded
209
+ return patch
210
+ }
211
+
212
+ function schemaValue(
213
+ schema: unknown,
214
+ key: string,
215
+ root: unknown = schema,
216
+ ): JsonValue {
217
+ if (!isRecord(schema)) return {}
218
+ if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
219
+ const name = schema.$ref.slice("#/$defs/".length)
220
+ if (isRecord(root) && isRecord(root.$defs))
221
+ return schemaValue(root.$defs[name], key, root)
222
+ }
223
+ if ("const" in schema && isJson(schema.const)) return schema.const
224
+ if (Array.isArray(schema.enum) && schema.enum.length > 0 && isJson(schema.enum[0]))
225
+ return schema.enum[0]
226
+ const alternative = Array.isArray(schema.anyOf)
227
+ ? schema.anyOf[0]
228
+ : Array.isArray(schema.oneOf)
229
+ ? schema.oneOf[0]
230
+ : undefined
231
+ if (alternative !== undefined) return schemaValue(alternative, key, root)
232
+ if (Array.isArray(schema.allOf) && schema.allOf.length > 0 && schema.type === undefined)
233
+ return schemaValue(schema.allOf[0], key, root)
234
+ if (schema.type === "object" || isRecord(schema.properties)) {
235
+ const properties = isRecord(schema.properties) ? schema.properties : {}
236
+ const required = Array.isArray(schema.required)
237
+ ? schema.required.filter((value): value is string => typeof value === "string")
238
+ : []
239
+ return Object.fromEntries(
240
+ required.map((name) => [name, schemaValue(properties[name], name, root)]),
241
+ )
242
+ }
243
+ if (schema.type === "array") {
244
+ const count = typeof schema.minItems === "number" ? Math.max(1, schema.minItems) : 1
245
+ return Array.from({ length: count }, () => schemaValue(schema.items, key, root))
246
+ }
247
+ if (schema.type === "boolean") return false
248
+ if (schema.type === "integer" || schema.type === "number") {
249
+ if (typeof schema.minimum === "number") return schema.minimum
250
+ if (typeof schema.exclusiveMinimum === "number") return schema.exclusiveMinimum + 1
251
+ return 1
252
+ }
253
+ if (schema.type === "null") return null
254
+ if (key.toLowerCase().includes("path")) return "."
255
+ if (key.toLowerCase().includes("pattern")) return "TODO"
256
+ if (key.toLowerCase().includes("command")) return "pwd"
257
+ if (key.toLowerCase().includes("content")) return "Generated by the simulated model."
258
+ const value = "sample"
259
+ return typeof schema.minLength === "number"
260
+ ? value.padEnd(schema.minLength, "x")
261
+ : value
262
+ }
263
+
264
+ function hasToolResult(body: unknown) {
265
+ if (!isRecord(body) || !Array.isArray(body.messages)) return false
266
+ const message = body.messages.at(-1)
267
+ if (!isRecord(message)) return false
268
+ if (message.role === "tool") return true
269
+ if (!Array.isArray(message.content)) return false
270
+ return message.content.some(
271
+ (part) => isRecord(part) && (part.type === "tool-result" || part.type === "tool"),
272
+ )
273
+ }
274
+
275
+ function acceptsValue(schema: unknown, value: JsonValue) {
276
+ if (!isRecord(schema)) return true
277
+ if (Array.isArray(schema.enum) && !schema.enum.some((item) => item === value))
278
+ return false
279
+ if (schema.type === "string") return typeof value === "string"
280
+ if (schema.type === "number" || schema.type === "integer")
281
+ return typeof value === "number"
282
+ if (schema.type === "boolean") return typeof value === "boolean"
283
+ if (schema.type === "array") return Array.isArray(value)
284
+ if (schema.type === "object") return isJsonRecord(value)
285
+ return true
286
+ }
287
+
288
+ function parseTypes(values: ReadonlyArray<string>) {
289
+ const types = unique(values)
290
+ if (types.length === 0) throw new Error("responses requires at least one type")
291
+ const valid = types.filter(isResponseType)
292
+ const unknown = types.filter((value) => !isResponseType(value))
293
+ if (unknown.length > 0)
294
+ throw new Error(`unknown response types: ${unknown.join(", ")}`)
295
+ return valid
296
+ }
297
+
298
+ function parseTools(values: ReadonlyArray<string>) {
299
+ const tools = unique(values)
300
+ if (tools.length === 0) throw new Error("responses requires at least one tool or *")
301
+ if (tools.some((tool) => tool !== "*" && !/^[a-zA-Z0-9_.:-]+$/.test(tool)))
302
+ throw new Error("tool names may contain only letters, numbers, dots, underscores, colons, or dashes")
303
+ return tools
304
+ }
305
+
306
+ function unique(values: ReadonlyArray<string>) {
307
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
308
+ }
309
+
310
+ function pick<T>(values: ReadonlyArray<T>) {
311
+ return values[Math.floor(Math.random() * values.length)]!
312
+ }
313
+
314
+ function isRecord(value: unknown): value is Record<string, unknown> {
315
+ return typeof value === "object" && value !== null && !Array.isArray(value)
316
+ }
317
+
318
+ function isJsonRecord(
319
+ value: JsonValue,
320
+ ): value is { readonly [key: string]: JsonValue } {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value)
322
+ }
323
+
324
+ function isJson(value: unknown): value is JsonValue {
325
+ if (value === null) return true
326
+ if (["boolean", "number", "string"].includes(typeof value)) return true
327
+ if (Array.isArray(value)) return value.every(isJson)
328
+ if (!isRecord(value)) return false
329
+ return Object.values(value).every(isJson)
330
+ }
331
+
332
+ function isResponseType(value: string): value is ResponseType {
333
+ return responseTypes.some((type) => type === value)
334
+ }
@@ -0,0 +1,20 @@
1
+ import { requestResponses } from "./control.js"
2
+ import { resolveInstance } from "./registry.js"
3
+
4
+ export async function responses(options: {
5
+ readonly name?: string
6
+ readonly types?: string
7
+ readonly tools?: string
8
+ }) {
9
+ const manifest = await resolveInstance(options.name)
10
+ const configuration = await requestResponses(manifest.control, {
11
+ ...(options.types === undefined ? {} : { types: split(options.types) }),
12
+ ...(options.tools === undefined ? {} : { tools: split(options.tools) }),
13
+ })
14
+ console.log(`Types: ${configuration.types.join(",")}`)
15
+ console.log(`Tools: ${configuration.tools.join(",")}`)
16
+ }
17
+
18
+ function split(value: string) {
19
+ return value.split(",").map((item) => item.trim())
20
+ }
@@ -1,7 +1,7 @@
1
1
  import { request } from "./control.js"
2
2
  import { resolveInstance } from "./registry.js"
3
3
 
4
- export async function restart(name: string) {
4
+ export async function restart(name?: string) {
5
5
  await request((await resolveInstance(name)).control, "restart")
6
6
  console.log("success")
7
7
  }
package/src/cli/script.ts CHANGED
@@ -44,6 +44,7 @@ export async function runScript(
44
44
  }
45
45
  signal.addEventListener("abort", abort, { once: true })
46
46
  try {
47
+ await waitForEditor(ui, signal)
47
48
  await Promise.race([
48
49
  script({ ui, backend, artifacts, signal }),
49
50
  new Promise<never>((_resolve, reject) => {
@@ -61,6 +62,16 @@ export async function runScript(
61
62
  }
62
63
  }
63
64
 
65
+ async function waitForEditor(ui: SimulationClient, signal: AbortSignal) {
66
+ const deadline = Date.now() + 30_000
67
+ while (Date.now() < deadline) {
68
+ signal.throwIfAborted()
69
+ if ((await ui.state()).focused.editor) return
70
+ await Bun.sleep(50)
71
+ }
72
+ throw new Error("timed out waiting for the prompt editor")
73
+ }
74
+
64
75
  function isDriveScript(value: unknown): value is DriveScript {
65
76
  return typeof value === "function"
66
77
  }