Skip to content

Commit 5a690de

Browse files
committed
cli: extract run from mini package
Give non-interactive execution its own entry so v1 can bridge onto the v2 run path without pulling interactive mini code. Keep legacy output quirks behind an explicit compatibility mode.
1 parent cf6e5b3 commit 5a690de

15 files changed

Lines changed: 498 additions & 50 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"./mini/footer.view": "./src/mini/footer.view.tsx",
2323
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
2424
"./mini/*": "./src/mini/*.ts",
25+
"./run": "./src/run/index.ts",
2526
"./server-process": "./src/server-process.ts"
2627
},
2728
"scripts": {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { defineScript } from "opencode-drive"
2+
import { mkdir } from "node:fs/promises"
3+
import path from "node:path"
4+
5+
export default defineScript({
6+
launch: "manual",
7+
setup({ config }) {
8+
config.autoupdate = false
9+
},
10+
async run({ artifacts, llm, server }) {
11+
await configureServicePort(artifacts)
12+
llm.queue(llm.text("drive noninteractive smoke ok"))
13+
await server.launch()
14+
15+
const registration = await serviceRegistration(artifacts)
16+
const root = path.resolve(import.meta.dir, "../../..")
17+
const directory = path.join(artifacts, "files")
18+
const child = Bun.spawn(
19+
[
20+
process.execPath,
21+
path.join(root, "packages/cli/src/index.ts"),
22+
"run",
23+
"--server",
24+
registration.url,
25+
"drive smoke",
26+
],
27+
{
28+
cwd: path.join(root, "packages/cli"),
29+
env: {
30+
...process.env,
31+
PWD: directory,
32+
OPENCODE_PASSWORD: registration.password,
33+
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
34+
OPENCODE_DISABLE_AUTOUPDATE: "1",
35+
},
36+
stdin: "ignore",
37+
stdout: "pipe",
38+
stderr: "pipe",
39+
},
40+
)
41+
const [exitCode, stdout, stderr] = await Promise.all([
42+
child.exited,
43+
new Response(child.stdout).text(),
44+
new Response(child.stderr).text(),
45+
])
46+
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
47+
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
48+
},
49+
})
50+
51+
/** @param {string} artifacts */
52+
async function configureServicePort(artifacts) {
53+
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
54+
const port = probe.port
55+
await probe.stop(true)
56+
if (!port) throw new Error("Failed to allocate a Drive service port")
57+
const file = path.join(artifacts, "files/.opencode/service-local.json")
58+
await mkdir(path.dirname(file), { recursive: true })
59+
await Bun.write(file, JSON.stringify({ port }))
60+
}
61+
62+
/** @param {string} artifacts */
63+
async function serviceRegistration(artifacts) {
64+
const directory = path.join(artifacts, "home/.local/state/opencode")
65+
for (let attempt = 0; attempt < 200; attempt++) {
66+
for (const name of ["service-local.json", "service.json"]) {
67+
const value = await Bun.file(path.join(directory, name))
68+
.json()
69+
.catch(() => undefined)
70+
if (isRegistration(value)) return value
71+
}
72+
await Bun.sleep(50)
73+
}
74+
throw new Error("Drive service registration was not written")
75+
}
76+
77+
/** @param {unknown} value */
78+
function isRegistration(value) {
79+
return (
80+
typeof value === "object" &&
81+
value !== null &&
82+
"url" in value &&
83+
typeof value.url === "string" &&
84+
"password" in value &&
85+
typeof value.password === "string"
86+
)
87+
}

packages/cli/src/commands/handlers/mini.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
55

66
export default Runtime.handler(Commands.commands.mini, (input) =>
77
Effect.gen(function* () {
8-
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
8+
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini/mini"))
99
yield* Effect.promise(async () => validateMiniTerminal())
1010
const serverURL = Option.getOrUndefined(input.server)
1111
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })

packages/cli/src/commands/handlers/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
55

66
export default Runtime.handler(Commands.commands.run, (input) =>
77
Effect.gen(function* () {
8-
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
8+
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
99
const separator = process.argv.indexOf("--", 2)
1010
const server = yield* ServerConnection.resolve({
1111
server: Option.getOrUndefined(input.server),

packages/cli/src/mini/index.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1 @@
11
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
2-
export {
3-
runNonInteractive,
4-
mergeInput as mergeNonInteractiveInput,
5-
pickRunModel,
6-
parseRunModel,
7-
type RunCommandInput,
8-
} from "./run"

packages/cli/src/run/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { runNonInteractive, type RunCommandInput } from "./run"
2+
export { runV1Bridge, type V1RunCommandInput } from "./v1"
Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
22
import { SessionMessage } from "@opencode-ai/schema/session-message"
33
import { EOL } from "node:os"
44
import { readFile } from "node:fs/promises"
5-
import { toolOutputText } from "./tool"
5+
import { toolOutputText } from "../mini/tool"
6+
import type { MiniToolPart } from "../mini/types"
67
import { UI } from "./ui"
7-
import type { MiniToolPart } from "./types"
88

99
type Model = {
1010
providerID: string
@@ -30,6 +30,7 @@ type Input = {
3030
auto: boolean
3131
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
3232
attached: boolean
33+
compatibility?: "v1"
3334
renderTool: (part: MiniToolPart) => Promise<void>
3435
renderToolError: (part: MiniToolPart) => Promise<void>
3536
}
@@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) {
7172
let permissionRejected = false
7273
let formCancelled = false
7374
let interrupted = false
75+
let v1InvalidOutput = false
7476
let admission: AbortController | undefined
77+
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
7578

7679
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
7780
if (input.format !== "json") return false
@@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) {
9295
UI.empty()
9396
}
9497

98+
const flushStep = () => {
99+
if (!pendingStep) return
100+
const value = pendingStep
101+
pendingStep = undefined
102+
if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") {
103+
UI.empty()
104+
UI.println(value.label)
105+
UI.empty()
106+
}
107+
}
108+
95109
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
96110
if (!input.auto) {
97111
permissionRejected = true
@@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) {
178192
type: "step-start",
179193
snapshot: event.data.snapshot,
180194
}
195+
if (input.compatibility === "v1") {
196+
pendingStep = {
197+
timestamp: time,
198+
part,
199+
label: `> ${event.data.agent} · ${event.data.model.id}`,
200+
}
201+
continue
202+
}
181203
if (!emit("step_start", time, { part }) && input.format !== "json") {
182204
UI.empty()
183205
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
@@ -187,6 +209,7 @@ export async function runNonInteractivePrompt(input: Input) {
187209
}
188210

189211
if (event.type === "session.text.started") {
212+
flushStep()
190213
starts.set("text", { id: partID(event.id), timestamp: time })
191214
continue
192215
}
@@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) {
206229
}
207230

208231
if (event.type === "session.reasoning.started") {
232+
flushStep()
209233
starts.set("reasoning", { id: partID(event.id), timestamp: time })
210234
continue
211235
}
@@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) {
236260
}
237261

238262
if (event.type === "session.tool.input.started") {
263+
flushStep()
239264
tools.set(event.data.callID, {
240265
id: partID(event.id),
241266
timestamp: time,
@@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) {
251276
continue
252277
}
253278
if (event.type === "session.tool.called") {
279+
flushStep()
254280
const current = tools.get(event.data.callID)
255281
tools.set(event.data.callID, {
256282
id: current?.id ?? partID(event.id),
@@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
316342
},
317343
}
318344
tools.delete(event.data.callID)
345+
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
319346
if (!emit("tool_use", time, { part })) {
320347
await input.renderToolError(part)
321348
UI.error(error)
@@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) {
324351
}
325352

326353
if (event.type === "session.step.ended") {
354+
flushStep()
327355
const part = {
328356
id: partID(event.id),
329357
sessionID: input.sessionID,
@@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) {
338366
continue
339367
}
340368
if (event.type === "session.step.failed") {
369+
if (
370+
input.compatibility === "v1" &&
371+
event.data.error.message === "Provider stream ended without a terminal finish event"
372+
) {
373+
pendingStep = undefined
374+
v1InvalidOutput = true
375+
continue
376+
}
341377
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
378+
flushStep()
342379
emittedError = true
343380
process.exitCode = 1
344381
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
345382
continue
346383
}
347384
if (event.type === "session.execution.failed") {
385+
if (
386+
input.compatibility === "v1" &&
387+
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
388+
)
389+
return
390+
flushStep()
348391
if (!emittedError && !questionRejected && !formCancelled) {
349392
emittedError = true
350393
process.exitCode = 1
@@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) {
353396
return
354397
}
355398
if (event.type === "session.execution.interrupted") {
399+
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
356400
if (event.data.reason === "user" && interrupted) process.exitCode = 130
357401
if (event.data.reason !== "user" && !emittedError) {
358402
emittedError = true
@@ -478,7 +522,7 @@ async function prepareFile(file: File) {
478522
const uri = file.url.startsWith("data:")
479523
? file.url
480524
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
481-
return { attachment: { uri, mime: file.mime, name: file.filename } }
525+
return { attachment: { uri, name: file.filename } }
482526
}
483527
const content = file.url.startsWith("data:")
484528
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")

0 commit comments

Comments
 (0)