Przeglądaj źródła

cli: extract run from mini package (#37737)

Simon Klee 4 tygodni temu
rodzic
commit
3f5ad8441f

+ 1 - 0
packages/cli/package.json

@@ -22,6 +22,7 @@
     "./mini/footer.view": "./src/mini/footer.view.tsx",
     "./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
     "./mini/*": "./src/mini/*.ts",
+    "./run": "./src/run/index.ts",
     "./server-process": "./src/server-process.ts"
   },
   "scripts": {

+ 1 - 1
packages/cli/src/commands/handlers/mini.ts

@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
 
 export default Runtime.handler(Commands.commands.mini, (input) =>
   Effect.gen(function* () {
-    const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
+    const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini/mini"))
     yield* Effect.promise(async () => validateMiniTerminal())
     const serverURL = Option.getOrUndefined(input.server)
     const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })

+ 1 - 1
packages/cli/src/commands/handlers/run.ts

@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
 
 export default Runtime.handler(Commands.commands.run, (input) =>
   Effect.gen(function* () {
-    const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
+    const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
     const separator = process.argv.indexOf("--", 2)
     const server = yield* ServerConnection.resolve({
       server: Option.getOrUndefined(input.server),

+ 0 - 7
packages/cli/src/mini/index.ts

@@ -1,8 +1 @@
 export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
-export {
-  runNonInteractive,
-  mergeInput as mergeNonInteractiveInput,
-  pickRunModel,
-  parseRunModel,
-  type RunCommandInput,
-} from "./run"

+ 2 - 0
packages/cli/src/run/index.ts

@@ -0,0 +1,2 @@
+export { runNonInteractive, type RunCommandInput } from "./run"
+export { runV1Bridge, type V1RunCommandInput } from "./v1"

+ 47 - 3
packages/cli/src/mini/noninteractive.ts → packages/cli/src/run/noninteractive.ts

@@ -2,9 +2,9 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { EOL } from "node:os"
 import { readFile } from "node:fs/promises"
-import { toolOutputText } from "./tool"
+import { toolOutputText } from "../mini/tool"
+import type { MiniToolPart } from "../mini/types"
 import { UI } from "./ui"
-import type { MiniToolPart } from "./types"
 
 type Model = {
   providerID: string
@@ -30,6 +30,7 @@ type Input = {
   auto: boolean
   /** True when the client is attached to a shared server rather than an exclusive in-process one. */
   attached: boolean
+  compatibility?: "v1"
   renderTool: (part: MiniToolPart) => Promise<void>
   renderToolError: (part: MiniToolPart) => Promise<void>
 }
@@ -71,7 +72,9 @@ export async function runNonInteractivePrompt(input: Input) {
   let permissionRejected = false
   let formCancelled = false
   let interrupted = false
+  let v1InvalidOutput = false
   let admission: AbortController | undefined
+  let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
 
   const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
     if (input.format !== "json") return false
@@ -92,6 +95,17 @@ export async function runNonInteractivePrompt(input: Input) {
     UI.empty()
   }
 
+  const flushStep = () => {
+    if (!pendingStep) return
+    const value = pendingStep
+    pendingStep = undefined
+    if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") {
+      UI.empty()
+      UI.println(value.label)
+      UI.empty()
+    }
+  }
+
   const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
     if (!input.auto) {
       permissionRejected = true
@@ -178,6 +192,14 @@ export async function runNonInteractivePrompt(input: Input) {
           type: "step-start",
           snapshot: event.data.snapshot,
         }
+        if (input.compatibility === "v1") {
+          pendingStep = {
+            timestamp: time,
+            part,
+            label: `> ${event.data.agent} · ${event.data.model.id}`,
+          }
+          continue
+        }
         if (!emit("step_start", time, { part }) && input.format !== "json") {
           UI.empty()
           UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
@@ -187,6 +209,7 @@ export async function runNonInteractivePrompt(input: Input) {
       }
 
       if (event.type === "session.text.started") {
+        flushStep()
         starts.set("text", { id: partID(event.id), timestamp: time })
         continue
       }
@@ -206,6 +229,7 @@ export async function runNonInteractivePrompt(input: Input) {
       }
 
       if (event.type === "session.reasoning.started") {
+        flushStep()
         starts.set("reasoning", { id: partID(event.id), timestamp: time })
         continue
       }
@@ -236,6 +260,7 @@ export async function runNonInteractivePrompt(input: Input) {
       }
 
       if (event.type === "session.tool.input.started") {
+        flushStep()
         tools.set(event.data.callID, {
           id: partID(event.id),
           timestamp: time,
@@ -251,6 +276,7 @@ export async function runNonInteractivePrompt(input: Input) {
         continue
       }
       if (event.type === "session.tool.called") {
+        flushStep()
         const current = tools.get(event.data.callID)
         tools.set(event.data.callID, {
           id: current?.id ?? partID(event.id),
@@ -316,6 +342,7 @@ export async function runNonInteractivePrompt(input: Input) {
           },
         }
         tools.delete(event.data.callID)
+        if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
         if (!emit("tool_use", time, { part })) {
           await input.renderToolError(part)
           UI.error(error)
@@ -324,6 +351,7 @@ export async function runNonInteractivePrompt(input: Input) {
       }
 
       if (event.type === "session.step.ended") {
+        flushStep()
         const part = {
           id: partID(event.id),
           sessionID: input.sessionID,
@@ -338,13 +366,28 @@ export async function runNonInteractivePrompt(input: Input) {
         continue
       }
       if (event.type === "session.step.failed") {
+        if (
+          input.compatibility === "v1" &&
+          event.data.error.message === "Provider stream ended without a terminal finish event"
+        ) {
+          pendingStep = undefined
+          v1InvalidOutput = true
+          continue
+        }
         if (interrupted || permissionRejected || questionRejected || formCancelled) continue
+        flushStep()
         emittedError = true
         process.exitCode = 1
         if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
         continue
       }
       if (event.type === "session.execution.failed") {
+        if (
+          input.compatibility === "v1" &&
+          (v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
+        )
+          return
+        flushStep()
         if (!emittedError && !questionRejected && !formCancelled) {
           emittedError = true
           process.exitCode = 1
@@ -353,6 +396,7 @@ export async function runNonInteractivePrompt(input: Input) {
         return
       }
       if (event.type === "session.execution.interrupted") {
+        if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
         if (event.data.reason === "user" && interrupted) process.exitCode = 130
         if (event.data.reason !== "user" && !emittedError) {
           emittedError = true
@@ -478,7 +522,7 @@ async function prepareFile(file: File) {
     const uri = file.url.startsWith("data:")
       ? file.url
       : `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
-    return { attachment: { uri, mime: file.mime, name: file.filename } }
+    return { attachment: { uri, name: file.filename } }
   }
   const content = file.url.startsWith("data:")
     ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")

+ 43 - 17
packages/cli/src/mini/run.ts → packages/cli/src/run/run.ts

@@ -6,10 +6,10 @@ import { open } from "node:fs/promises"
 import path from "node:path"
 import { readStdin } from "../util/io"
 import { ServerConnection } from "../services/server-connection"
-import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
+import { loadRunAgents, waitForCatalogReady } from "../mini/catalog.shared"
+import { toolInlineInfo } from "../mini/tool"
+import type { MiniToolPart } from "../mini/types"
 import { runNonInteractivePrompt } from "./noninteractive"
-import { toolInlineInfo } from "./tool"
-import type { MiniToolPart } from "./types"
 import { UI } from "./ui"
 
 export type RunCommandInput = {
@@ -39,24 +39,39 @@ type Prepared = {
   files: FilePart[]
 }
 
+type ExecutionOptions = {
+  root?: string
+  directory?: string
+  useServerDirectory?: boolean
+  variant?: string
+  attached?: boolean
+  compatibility?: "v1"
+}
+
 const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
 
 export function runNonInteractive(input: RunCommandInput) {
-  return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
+  return runNonInteractiveWithOptions(input, {})
 }
 
-async function run(input: RunCommandInput) {
+/** @internal Used only by the V1 command boundary. */
+export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) {
+  return run(input, options).catch((error) => reportRunError(input, errorMessage(error)))
+}
+
+async function run(input: RunCommandInput, options: ExecutionOptions) {
   if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
-  const root = process.env.PWD ?? process.cwd()
-  const directory = localDirectory(root)
+  const root = options.root ?? process.env.PWD ?? process.cwd()
+  const local = localDirectory(root)
+  const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
   const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
   if (!message?.trim()) fail("You must provide a message")
-  const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
+  const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options)))
   const prepared = { directory, message, files }
-  return execute(input, prepared, input.server.endpoint)
+  return execute(input, prepared, input.server.endpoint, options)
 }
 
-async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
+async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
   const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
   const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
   if (!requestedDirectory) fail("Failed to resolve server directory")
@@ -65,7 +80,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
   const workspace = session?.location.workspaceID
   const explicit = parseRunModel(input.model)
   const explicitModel = explicit?.model
-  const variant = explicit?.variant
+  const variant = options.variant ?? explicit?.variant
   const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
   const defaultModel =
     !explicitModel && !sessionModel
@@ -74,12 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
           .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
       : undefined
   const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
-  if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
+  if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
   if (model) {
     await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
     const available = await client.model.list({ location: { directory: cwd, workspace } })
     if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
-      return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
+      return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
   }
   const agent = await validateAgent(client, cwd, input.agent)
   const selected =
@@ -107,10 +122,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
     thinking: input.thinking ?? false,
     format: input.format,
     auto: input.auto ?? false,
-    attached: true,
+    attached: options.attached ?? true,
+    compatibility: options.compatibility,
     renderTool,
     renderToolError,
-  }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
+  }).catch((error) => reportRunError(input, errorMessage(error), selected.id))
 }
 
 export function mergeInput(message: string | undefined, piped: string | undefined) {
@@ -185,11 +201,13 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
   return client.session.fork({ sessionID: selected.id })
 }
 
-async function prepareFile(input: string, directory: string): Promise<FilePart> {
+async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
   const file = path.resolve(directory, input)
   const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
   try {
     const stat = await handle.stat()
+    if (options.compatibility === "v1" && options.attached && stat.isDirectory())
+      fail(`Cannot attach local directory without a shared filesystem: ${input}`)
     if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
       fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
     const content = Buffer.alloc(Number(stat.size))
@@ -249,7 +267,15 @@ function warning(message: string) {
   UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
 }
 
-function reportError(input: RunCommandInput, message: string, sessionID?: string) {
+function errorMessage(error: unknown) {
+  if (error instanceof Error) return error.message
+  if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+    return error.message
+  return String(error)
+}
+
+/** @internal Used by the V1 command boundary before a Session exists. */
+export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
   process.exitCode = 1
   if (input.format === "json") {
     process.stdout.write(

+ 0 - 0
packages/cli/src/mini/ui.ts → packages/cli/src/run/ui.ts


+ 85 - 0
packages/cli/src/run/v1.ts

@@ -0,0 +1,85 @@
+import type { Endpoint } from "@opencode-ai/client/effect/service"
+import { Effect } from "effect"
+import path from "node:path"
+import { Standalone } from "../services/standalone"
+import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
+
+export type V1RunCommandInput = {
+  message: string[]
+  continue?: boolean
+  session?: string
+  fork?: boolean
+  model?: string
+  agent?: string
+  format: "default" | "json"
+  file: string[]
+  title?: string
+  server?: string
+  password?: string
+  username?: string
+  directory?: string
+  variant?: string
+  thinking?: boolean
+  dangerouslySkipPermissions?: boolean
+  standaloneCommand?: ReadonlyArray<string>
+}
+
+export function runV1Bridge(input: V1RunCommandInput) {
+  const root = process.env.PWD ?? process.cwd()
+  const attached = input.server !== undefined
+  const local = !attached && input.directory ? path.resolve(root, input.directory) : root
+  try {
+    process.chdir(local)
+  } catch {
+    reportRunError(input, `Failed to change directory to ${local}`)
+    return Promise.resolve()
+  }
+
+  return Effect.runPromise(
+    Effect.scoped(
+      Effect.gen(function* () {
+        const endpoint = attached
+          ? explicitEndpoint(input)
+          : yield* Standalone.start({ command: input.standaloneCommand })
+        yield* Effect.promise(() =>
+          runNonInteractiveWithOptions(nativeInput(input, endpoint), {
+            root: local,
+            directory: attached ? input.directory : local,
+            useServerDirectory: attached && input.directory === undefined,
+            variant: input.variant,
+            attached,
+            compatibility: "v1",
+          }),
+        )
+      }),
+    ),
+  ).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
+}
+
+function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
+  return {
+    server: { endpoint },
+    message: input.message,
+    continue: input.continue,
+    session: input.session,
+    fork: input.fork,
+    model: input.model,
+    agent: input.agent,
+    format: input.format,
+    file: input.file,
+    title: input.title,
+    thinking: input.thinking,
+    auto: input.dangerouslySkipPermissions,
+  }
+}
+
+function explicitEndpoint(input: V1RunCommandInput): Endpoint {
+  const url = input.server
+  if (!url) throw new Error("Missing V1 server URL")
+  return {
+    url,
+    auth: input.password
+      ? { type: "basic", username: input.username ?? "opencode", password: input.password }
+      : undefined,
+  }
+}

+ 87 - 0
packages/cli/test/drive/run-smoke.drive.mjs

@@ -0,0 +1,87 @@
+import { defineScript } from "opencode-drive"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+
+export default defineScript({
+  launch: "manual",
+  setup({ config }) {
+    config.autoupdate = false
+  },
+  async run({ artifacts, llm, server }) {
+    await configureServicePort(artifacts)
+    llm.queue(llm.text("drive noninteractive smoke ok"))
+    await server.launch()
+
+    const registration = await serviceRegistration(artifacts)
+    const root = path.resolve(import.meta.dir, "../../../..")
+    const directory = path.join(artifacts, "files")
+    const child = Bun.spawn(
+      [
+        process.execPath,
+        path.join(root, "packages/cli/src/index.ts"),
+        "run",
+        "--server",
+        registration.url,
+        "drive smoke",
+      ],
+      {
+        cwd: path.join(root, "packages/cli"),
+        env: {
+          ...process.env,
+          PWD: directory,
+          OPENCODE_PASSWORD: registration.password,
+          OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
+          OPENCODE_DISABLE_AUTOUPDATE: "1",
+        },
+        stdin: "ignore",
+        stdout: "pipe",
+        stderr: "pipe",
+      },
+    )
+    const [exitCode, stdout, stderr] = await Promise.all([
+      child.exited,
+      new Response(child.stdout).text(),
+      new Response(child.stderr).text(),
+    ])
+    if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
+    if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
+  },
+})
+
+/** @param {string} artifacts */
+async function configureServicePort(artifacts) {
+  const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
+  const port = probe.port
+  await probe.stop(true)
+  if (!port) throw new Error("Failed to allocate a Drive service port")
+  const file = path.join(artifacts, "files/.opencode/service-local.json")
+  await mkdir(path.dirname(file), { recursive: true })
+  await Bun.write(file, JSON.stringify({ port }))
+}
+
+/** @param {string} artifacts */
+async function serviceRegistration(artifacts) {
+  const directory = path.join(artifacts, "home/.local/state/opencode")
+  for (let attempt = 0; attempt < 200; attempt++) {
+    for (const name of ["service-local.json", "service.json"]) {
+      const value = await Bun.file(path.join(directory, name))
+        .json()
+        .catch(() => undefined)
+      if (isRegistration(value)) return value
+    }
+    await Bun.sleep(50)
+  }
+  throw new Error("Drive service registration was not written")
+}
+
+/** @param {unknown} value */
+function isRegistration(value) {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "url" in value &&
+    typeof value.url === "string" &&
+    "password" in value &&
+    typeof value.password === "string"
+  )
+}

+ 60 - 0
packages/cli/test/import-boundaries.test.ts

@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import path from "node:path"
+
+const root = path.resolve(import.meta.dir, "..")
+
+describe("CLI frontend import boundaries", () => {
+  test("exposes only the run entrypoints from the run package export", async () => {
+    const entrypoint = await import("@opencode-ai/cli/run")
+    const mini = await import("@opencode-ai/cli/mini")
+
+    expect(Object.keys(entrypoint).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
+    expect(Object.keys(mini).sort()).toEqual(["mergeInteractiveInput", "runMini", "validateMiniTerminal"])
+  })
+
+  test("keeps run and Mini handlers on separate leaf graphs", async () => {
+    const run = await bundleInputs("src/commands/handlers/run.ts")
+    expect(run).toContain("src/run/run.ts")
+    expect(run).not.toContain("src/mini/mini.ts")
+    expect(run).not.toContain("src/mini/runtime.ts")
+
+    const mini = await bundleInputs("src/commands/handlers/mini.ts")
+    expect(mini).toContain("src/mini/mini.ts")
+    expect(mini).not.toContain("src/run/run.ts")
+    expect(mini).not.toContain("src/run/noninteractive.ts")
+    expect(mini).not.toContain("src/run/ui.ts")
+  })
+})
+
+async function bundleInputs(entrypoint: string) {
+  const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
+  const metafile = path.join(temporary, "meta.json")
+  try {
+    const child = Bun.spawn(
+      [
+        process.execPath,
+        "build",
+        entrypoint,
+        "--target=bun",
+        "--format=esm",
+        "--packages=external",
+        `--metafile=${metafile}`,
+        `--outdir=${path.join(temporary, "out")}`,
+      ],
+      { cwd: root, stdout: "pipe", stderr: "pipe" },
+    )
+    const [exitCode, stdout, stderr] = await Promise.all([
+      child.exited,
+      new Response(child.stdout).text(),
+      new Response(child.stderr).text(),
+    ])
+    if (exitCode !== 0) throw new Error(stdout + stderr)
+    const metadata = await Bun.file(metafile).json()
+    return Object.keys(metadata.inputs).map((input) =>
+      path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
+    )
+  } finally {
+    await rm(temporary, { recursive: true, force: true })
+  }
+}

+ 4 - 10
packages/cli/test/mini.test.ts

@@ -1,8 +1,9 @@
 import { describe, expect, test } from "bun:test"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import path from "node:path"
-import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
+import { mergeInput as mergeInteractiveInput } from "../src/mini/mini"
 import { toolInlineInfo, toolOutputText, toolView } from "../src/mini/tool"
+import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
 
 async function cli(args: string[]) {
   const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -59,7 +60,7 @@ describe("mini command", () => {
     expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
   })
 
-  test("keeps run as mini's non-interactive input mode", () => {
+  test("merges non-interactive argument and stdin input", () => {
     expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
     expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
   })
@@ -128,14 +129,7 @@ describe("mini command", () => {
     })
 
     try {
-      const result = await cli([
-        "run",
-        "--server",
-        server.url.toString(),
-        "--model",
-        "definitely/missing",
-        "hi",
-      ])
+      const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
 
       expect(result.exitCode).toBe(1)
       expect(result.stderr).toContain("Model unavailable: definitely/missing")

+ 128 - 3
packages/opencode/test/cli/run/noninteractive.test.ts → packages/cli/test/run/noninteractive.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
-import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
+import { runNonInteractivePrompt } from "../../src/run/noninteractive"
 
 type V2Event = EventSubscribeOutput
 type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
@@ -50,9 +50,57 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
   }
 }
 
+function stepStarted(): V2Event {
+  return {
+    id: "evt_step_started",
+    created: 1,
+    type: "session.step.started",
+    durable: { aggregateID: "ses_1", seq: 1, version: 1 },
+    data: {
+      sessionID: "ses_1",
+      assistantMessageID: "msg_assistant",
+      agent: "build",
+      model: { providerID: "test", id: "test-model" },
+    },
+  }
+}
+
+function stepFailed(message: string): V2Event {
+  return {
+    id: "evt_step_failed",
+    created: 2,
+    type: "session.step.failed",
+    durable: { aggregateID: "ses_1", seq: 2, version: 1 },
+    data: {
+      sessionID: "ses_1",
+      assistantMessageID: "msg_assistant",
+      error: { type: "provider.transport", message },
+    },
+  }
+}
+
+function executionFailed(message: string): V2Event {
+  return {
+    id: "evt_execution_failed",
+    created: 3,
+    type: "session.execution.failed",
+    durable: { aggregateID: "ses_1", seq: 3, version: 1 },
+    data: {
+      sessionID: "ses_1",
+      error: { type: "provider.transport", message },
+    },
+  }
+}
+
 // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
 // live events the prompt admission triggers, keyed by the generated message ID.
-async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
+async function run(input: {
+  turn: (inputID: string) => V2Event[]
+  pendingForms?: FormInfo[]
+  attached?: boolean
+  format?: "default" | "json"
+  compatibility?: "v1"
+}) {
   const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
   const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
   let wake: (() => void) | undefined
@@ -88,15 +136,38 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
     message: "hello",
     files: [],
     thinking: false,
-    format: "default",
+    format: input.format ?? "default",
     auto: false,
     attached: input.attached ?? false,
+    compatibility: input.compatibility,
     renderTool: () => Promise.resolve(),
     renderToolError: () => Promise.resolve(),
   })
   return sdk
 }
 
+async function capture(input: Parameters<typeof run>[0]) {
+  const stdout: string[] = []
+  const stderr: string[] = []
+  const exitCode = process.exitCode
+  const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => {
+    stdout.push(String(chunk))
+    return true
+  })
+  const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
+    stderr.push(String(chunk))
+    return true
+  })
+  try {
+    await run(input)
+    return { stdout: stdout.join(""), stderr: stderr.join("") }
+  } finally {
+    process.exitCode = exitCode ?? 0
+    stdoutWrite.mockRestore()
+    stderrWrite.mockRestore()
+  }
+}
+
 afterEach(() => {
   mock.restore()
 })
@@ -125,4 +196,58 @@ describe("runNonInteractivePrompt", () => {
     expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
     expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
   })
+
+  test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
+    const output = await capture({
+      compatibility: "v1",
+      format: "json",
+      turn: (messageID) => [
+        prompted(messageID),
+        stepStarted(),
+        stepFailed("Provider request failed"),
+        executionFailed("Provider request failed"),
+      ],
+    })
+
+    expect(
+      output.stdout
+        .split("\n")
+        .filter(Boolean)
+        .map((line) => JSON.parse(line)),
+    ).toEqual([
+      expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }),
+      expect.objectContaining({
+        type: "error",
+        error: { type: "provider.transport", message: "Provider request failed" },
+      }),
+    ])
+    expect(output.stderr).toBe("")
+  })
+
+  test("V1 default output flushes step_start before an unrelated execution failure", async () => {
+    const output = await capture({
+      compatibility: "v1",
+      turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")],
+    })
+
+    expect(output.stdout).toBe("")
+    expect(output.stderr).toContain("> build · test-model")
+    expect(output.stderr).toContain("Error: \u001b[0mExecution failed")
+    expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed"))
+  })
+
+  test("V1 preserves terminal-finish failure suppression before content", async () => {
+    const output = await capture({
+      compatibility: "v1",
+      format: "json",
+      turn: (messageID) => [
+        prompted(messageID),
+        stepStarted(),
+        stepFailed("Provider stream ended without a terminal finish event"),
+        executionFailed("Provider stream ended without a terminal finish event"),
+      ],
+    })
+
+    expect(output).toEqual({ stdout: "", stderr: "" })
+  })
 })

+ 2 - 3
packages/opencode/src/cli/cmd/run.ts

@@ -99,9 +99,9 @@ export const RunCommand = effectCmd({
         default: false,
       }),
   handler: Effect.fn("Cli.run")(function* (args) {
-    const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini"))
+    const { runV1Bridge } = yield* Effect.promise(() => import("@opencode-ai/cli/run"))
     yield* Effect.promise(() =>
-      runNonInteractive({
+      runV1Bridge({
         message: [...args.message, ...(args["--"] || [])],
         continue: args.continue,
         session: args.session,
@@ -112,7 +112,6 @@ export const RunCommand = effectCmd({
         file: args.file ?? [],
         title: args.title,
         server: args.server ?? args.attach,
-        // @ts-expect-error V1 does not consume the V2-only resolved server input.
         password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
         username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
         directory: args.dir,

+ 39 - 5
packages/opencode/test/cli/run/run-process.test.ts

@@ -5,10 +5,13 @@
 // an isolated test provider config under the fixture's temp home.
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
+import path from "node:path"
 import { reply } from "../../lib/llm-server"
 import { cliIt } from "../../lib/cli-process"
 import { testProviderConfig } from "../../lib/test-provider"
 
+const opencodeRoot = path.resolve(import.meta.dir, "../../..")
+
 describe("opencode run (non-interactive subprocess)", () => {
   // Happy path: prompt completes, output reaches stdout, process exits 0.
   // If this fails, all the others likely will too — debug here first.
@@ -367,9 +370,12 @@ describe("opencode run (non-interactive subprocess)", () => {
     ({ llm, opencode }) =>
       Effect.gen(function* () {
         yield* llm.text("variant response")
-        const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
-          config: { ...testProviderConfig(llm.url), model: "test/test-model" },
-        })
+        const result = yield* opencode.spawn(
+          ["run", "--model", "test/test-model", "--variant", "default", "use the model"],
+          {
+            config: { ...testProviderConfig(llm.url), model: "test/test-model" },
+          },
+        )
 
         opencode.expectExit(result, 0)
         expect(result.stdout).toBe("variant response\n")
@@ -382,7 +388,15 @@ describe("opencode run (non-interactive subprocess)", () => {
     ({ home, llm, opencode }) =>
       Effect.gen(function* () {
         const source = `${home}/image.png`
-        yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64")))
+        yield* Effect.promise(() =>
+          Bun.write(
+            source,
+            Buffer.from(
+              "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
+              "base64",
+            ),
+          ),
+        )
         yield* llm.text("attachment received")
         const config = testProviderConfig(llm.url)
         config.provider.test.models["test-model"].attachment = true
@@ -395,7 +409,7 @@ describe("opencode run (non-interactive subprocess)", () => {
         opencode.expectExit(result, 0)
         const input = JSON.stringify(yield* llm.inputs)
         expect(input).toContain("image/png")
-        expect(input).not.toContain("<file name=\\\"image.png\\\">")
+        expect(input).not.toContain('<file name=\\"image.png\\">')
       }),
     60_000,
   )
@@ -422,6 +436,26 @@ describe("opencode run (non-interactive subprocess)", () => {
     60_000,
   )
 
+  cliIt.live(
+    "attach mode without --dir uses the remote server location",
+    ({ home, llm, opencode }) =>
+      Effect.gen(function* () {
+        expect(home).not.toBe(opencodeRoot)
+        yield* llm.text("remote location used")
+        const server = yield* opencode.serve()
+
+        const result = yield* opencode.run("use the server location", {
+          extraArgs: ["--attach", server.url],
+        })
+
+        opencode.expectExit(result, 0)
+        const input = JSON.stringify(yield* llm.inputs)
+        expect(input).toContain(`Working directory: ${opencodeRoot}`)
+        expect(input).not.toContain(`Working directory: ${home}`)
+      }),
+    60_000,
+  )
+
   cliIt.concurrent(
     "attach mode rejects local directories before prompt admission",
     ({ home, opencode }) =>