Simon Klee пре 1 месец
родитељ
комит
32cf36de9d
93 измењених фајлова са 2244 додато и 2333 уклоњено
  1. 8 0
      bun.lock
  2. 21 1
      packages/cli/package.json
  3. 105 0
      packages/cli/src/commands/commands.ts
  4. 35 0
      packages/cli/src/commands/handlers/mini.ts
  5. 31 0
      packages/cli/src/commands/handlers/run.ts
  6. 8 146
      packages/cli/src/commands/handlers/serve.ts
  7. 70 0
      packages/cli/src/daemon.ts
  8. 2 0
      packages/cli/src/index.ts
  9. 36 31
      packages/cli/src/mini/catalog.shared.ts
  10. 1 1
      packages/cli/src/mini/demo.ts
  11. 0 0
      packages/cli/src/mini/entry.body.ts
  12. 0 0
      packages/cli/src/mini/footer.command.tsx
  13. 1 1
      packages/cli/src/mini/footer.menu.tsx
  14. 0 0
      packages/cli/src/mini/footer.permission.tsx
  15. 6 3
      packages/cli/src/mini/footer.prompt.tsx
  16. 0 0
      packages/cli/src/mini/footer.question.tsx
  17. 0 0
      packages/cli/src/mini/footer.subagent.tsx
  18. 11 1
      packages/cli/src/mini/footer.ts
  19. 1 1
      packages/cli/src/mini/footer.view.tsx
  20. 0 0
      packages/cli/src/mini/footer.width.ts
  21. 7 0
      packages/cli/src/mini/index.ts
  22. 234 0
      packages/cli/src/mini/mini.ts
  23. 34 40
      packages/cli/src/mini/noninteractive.ts
  24. 0 0
      packages/cli/src/mini/permission.shared.ts
  25. 0 0
      packages/cli/src/mini/prompt.editor.ts
  26. 1 1
      packages/cli/src/mini/prompt.shared.ts
  27. 0 0
      packages/cli/src/mini/question.shared.ts
  28. 308 0
      packages/cli/src/mini/run.ts
  29. 7 40
      packages/cli/src/mini/runtime.boot.ts
  30. 2 2
      packages/cli/src/mini/runtime.lifecycle.ts
  31. 6 5
      packages/cli/src/mini/runtime.queue.ts
  32. 0 0
      packages/cli/src/mini/runtime.shared.ts
  33. 0 0
      packages/cli/src/mini/runtime.stdin.ts
  34. 104 71
      packages/cli/src/mini/runtime.ts
  35. 0 0
      packages/cli/src/mini/scrollback.shared.ts
  36. 0 0
      packages/cli/src/mini/scrollback.surface.ts
  37. 0 0
      packages/cli/src/mini/scrollback.writer.tsx
  38. 1 1
      packages/cli/src/mini/session-data.ts
  39. 12 11
      packages/cli/src/mini/session.shared.ts
  40. 2 2
      packages/cli/src/mini/splash.ts
  41. 19 23
      packages/cli/src/mini/stream-v2.subagent.ts
  42. 45 51
      packages/cli/src/mini/stream-v2.transport.ts
  43. 0 0
      packages/cli/src/mini/stream.ts
  44. 0 0
      packages/cli/src/mini/theme.ts
  45. 138 116
      packages/cli/src/mini/tool.ts
  46. 0 0
      packages/cli/src/mini/trace.ts
  47. 0 0
      packages/cli/src/mini/turn-summary.ts
  48. 24 11
      packages/cli/src/mini/types.ts
  49. 27 0
      packages/cli/src/mini/ui.ts
  50. 5 2
      packages/cli/src/mini/variant.shared.ts
  51. 134 0
      packages/cli/src/server-process.ts
  52. 15 5
      packages/cli/src/services/standalone.ts
  53. 80 0
      packages/cli/test/mini.test.ts
  54. 33 11
      packages/client/src/effect/service.ts
  55. 1 1
      packages/client/src/promise/generated/types.ts
  56. 1 0
      packages/opencode/package.json
  57. 0 172
      packages/opencode/src/cli/cmd/mini.ts
  58. 36 1064
      packages/opencode/src/cli/cmd/run.ts
  59. 31 0
      packages/opencode/src/cli/cmd/v2-serve.ts
  60. 8 0
      packages/opencode/src/cli/cmd/v2-server-command.ts
  61. 2 2
      packages/opencode/src/index.ts
  62. 2 2
      packages/opencode/src/temporary.ts
  63. 18 79
      packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap
  64. 1 3
      packages/opencode/test/cli/help/help-snapshots.test.ts
  65. 13 26
      packages/opencode/test/cli/run/catalog.shared.test.ts
  66. 2 2
      packages/opencode/test/cli/run/entry.body.test.ts
  67. 1 1
      packages/opencode/test/cli/run/footer.menu.test.ts
  68. 8 8
      packages/opencode/test/cli/run/footer.view.test.tsx
  69. 1 1
      packages/opencode/test/cli/run/footer.width.test.ts
  70. 21 27
      packages/opencode/test/cli/run/noninteractive.test.ts
  71. 1 1
      packages/opencode/test/cli/run/permission.shared.test.ts
  72. 2 2
      packages/opencode/test/cli/run/prompt.editor.test.ts
  73. 2 2
      packages/opencode/test/cli/run/prompt.shared.test.ts
  74. 1 1
      packages/opencode/test/cli/run/question.shared.test.ts
  75. 2 2
      packages/opencode/test/cli/run/run-process.test.ts
  76. 27 49
      packages/opencode/test/cli/run/runtime.boot.test.ts
  77. 2 2
      packages/opencode/test/cli/run/runtime.queue.test.ts
  78. 1 1
      packages/opencode/test/cli/run/runtime.stdin.test.ts
  79. 281 106
      packages/opencode/test/cli/run/runtime.test.ts
  80. 3 3
      packages/opencode/test/cli/run/scrollback.surface.test.ts
  81. 2 2
      packages/opencode/test/cli/run/session-data.test.ts
  82. 16 29
      packages/opencode/test/cli/run/session.shared.test.ts
  83. 99 128
      packages/opencode/test/cli/run/stream-v2.transport.test.ts
  84. 2 2
      packages/opencode/test/cli/run/stream.test.ts
  85. 1 1
      packages/opencode/test/cli/run/theme.test.ts
  86. 3 3
      packages/opencode/test/cli/run/variant.shared.test.ts
  87. 1 32
      packages/opencode/test/cli/tui/thread.test.ts
  88. 5 1
      packages/protocol/src/groups/health.ts
  89. 2 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  90. 1 0
      packages/server/package.json
  91. 4 1
      packages/server/src/handlers/health.ts
  92. 64 0
      packages/server/src/process.ts
  93. 2 0
      packages/tui/package.json

+ 8 - 0
bun.lock

@@ -101,16 +101,22 @@
         "@effect/platform-node": "catalog:",
         "@opencode-ai/client": "workspace:*",
         "@opencode-ai/core": "workspace:*",
+        "@opencode-ai/plugin": "workspace:*",
+        "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/server": "workspace:*",
         "@opencode-ai/tui": "workspace:*",
         "@opentui/core": "catalog:",
+        "@opentui/keymap": "catalog:",
         "@opentui/solid": "catalog:",
         "@parcel/watcher": "2.5.1",
         "effect": "catalog:",
+        "fuzzysort": "catalog:",
         "jsonc-parser": "3.3.1",
+        "opentui-spinner": "catalog:",
         "semver": "catalog:",
         "solid-js": "catalog:",
+        "strip-ansi": "7.1.2",
       },
       "devDependencies": {
         "@opencode-ai/script": "workspace:*",
@@ -599,6 +605,7 @@
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
+        "@opencode-ai/cli": "workspace:*",
         "@opencode-ai/client": "workspace:*",
         "@opencode-ai/llm": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
@@ -796,6 +803,7 @@
       "name": "@opencode-ai/server",
       "version": "1.17.13",
       "dependencies": {
+        "@effect/platform-node": "catalog:",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/protocol": "workspace:*",
         "@opencode-ai/simulation": "workspace:*",

+ 21 - 1
packages/cli/package.json

@@ -10,6 +10,20 @@
   "files": [
     "bin"
   ],
+  "exports": {
+    "./daemon": "./src/daemon.ts",
+    "./mini": "./src/mini/index.ts",
+    "./mini/footer.command": "./src/mini/footer.command.tsx",
+    "./mini/footer.menu": "./src/mini/footer.menu.tsx",
+    "./mini/footer.permission": "./src/mini/footer.permission.tsx",
+    "./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
+    "./mini/footer.question": "./src/mini/footer.question.tsx",
+    "./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
+    "./mini/footer.view": "./src/mini/footer.view.tsx",
+    "./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
+    "./mini/*": "./src/mini/*.ts",
+    "./server-process": "./src/server-process.ts"
+  },
   "scripts": {
     "build": "bun run script/build.ts",
     "dev": "bun run src/index.ts",
@@ -19,16 +33,22 @@
     "@effect/platform-node": "catalog:",
     "@opencode-ai/client": "workspace:*",
     "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/plugin": "workspace:*",
+    "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/server": "workspace:*",
     "@opencode-ai/tui": "workspace:*",
     "@opentui/core": "catalog:",
+    "@opentui/keymap": "catalog:",
     "@opentui/solid": "catalog:",
     "@parcel/watcher": "2.5.1",
     "effect": "catalog:",
+    "fuzzysort": "catalog:",
     "jsonc-parser": "3.3.1",
+    "opentui-spinner": "catalog:",
     "semver": "catalog:",
-    "solid-js": "catalog:"
+    "solid-js": "catalog:",
+    "strip-ansi": "7.1.2"
   },
   "devDependencies": {
     "@opencode-ai/script": "workspace:*",

+ 105 - 0
packages/cli/src/commands/commands.ts

@@ -3,6 +3,31 @@ import { Spec } from "../framework/spec"
 
 declare const OPENCODE_CLI_NAME: string | undefined
 
+const MiniParams = {
+  continue: Flag.boolean("continue").pipe(
+    Flag.withAlias("c"),
+    Flag.withDescription("Continue the last session"),
+    Flag.withDefault(false),
+  ),
+  session: Flag.string("session").pipe(
+    Flag.withAlias("s"),
+    Flag.withDescription("Session ID to continue"),
+    Flag.optional,
+  ),
+  fork: Flag.boolean("fork").pipe(
+    Flag.withDescription("Fork the session when continuing"),
+    Flag.withDefault(false),
+  ),
+  replay: Flag.boolean("replay").pipe(
+    Flag.withDescription("Replay session history on resume and after resize"),
+    Flag.withDefault(true),
+  ),
+  replayLimit: Flag.integer("replay-limit").pipe(
+    Flag.withDescription("Cap visible replay to the newest N messages"),
+    Flag.optional,
+  ),
+}
+
 export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
   description: "OpenCode 2.0 preview command line interface",
   params: {
@@ -88,6 +113,86 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
       ],
     }),
     Spec.make("migrate", { description: "Migrate v1 data to v2" }),
+    Spec.make("mini", {
+      description: "Start the minimal interactive interface",
+      params: {
+        ...MiniParams,
+        project: Argument.string("project").pipe(
+          Argument.withDescription("Path to start OpenCode in"),
+          Argument.optional,
+        ),
+        model: Flag.string("model").pipe(
+          Flag.withAlias("m"),
+          Flag.withDescription("Model to use in the format provider/model"),
+          Flag.optional,
+        ),
+        agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
+        prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
+        server: Flag.string("server").pipe(
+          Flag.withDescription("Connect to a server URL instead of the background service"),
+          Flag.optional,
+        ),
+        demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
+      },
+    }),
+    Spec.make("run", {
+      description: "Run OpenCode with a message",
+      params: {
+        message: Argument.string("message").pipe(
+          Argument.withDescription("Message to send"),
+          Argument.variadic({ min: 0 }),
+        ),
+        continue: Flag.boolean("continue").pipe(
+          Flag.withAlias("c"),
+          Flag.withDescription("Continue the last session"),
+          Flag.withDefault(false),
+        ),
+        session: Flag.string("session").pipe(
+          Flag.withAlias("s"),
+          Flag.withDescription("Session ID to continue"),
+          Flag.optional,
+        ),
+        fork: Flag.boolean("fork").pipe(
+          Flag.withDescription("Fork the session before continuing"),
+          Flag.withDefault(false),
+        ),
+        model: Flag.string("model").pipe(
+          Flag.withAlias("m"),
+          Flag.withDescription("Model to use in the format provider/model"),
+          Flag.optional,
+        ),
+        agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
+        format: Flag.choice("format", ["default", "json"]).pipe(
+          Flag.withDescription("Output format"),
+          Flag.withDefault("default"),
+        ),
+        file: Flag.string("file").pipe(
+          Flag.withAlias("f"),
+          Flag.withDescription("File to attach to the message"),
+          Flag.atMost(100),
+        ),
+        title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
+        server: Flag.string("server").pipe(
+          Flag.withDescription("Connect to a server URL instead of the background service"),
+          Flag.optional,
+        ),
+        dir: Flag.string("dir").pipe(Flag.withDescription("Directory to run in"), Flag.optional),
+        variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional),
+        thinking: Flag.boolean("thinking").pipe(
+          Flag.withDescription("Show thinking blocks"),
+          Flag.withDefault(false),
+        ),
+        auto: Flag.boolean("auto").pipe(
+          Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
+          Flag.withDefault(false),
+        ),
+        yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
+        dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
+          Flag.withDefault(false),
+          Flag.withHidden,
+        ),
+      },
+    }),
     Spec.make("service", {
       description: "Manage the background server",
       commands: [

+ 35 - 0
packages/cli/src/commands/handlers/mini.ts

@@ -0,0 +1,35 @@
+import { Effect, Option, Redacted } from "effect"
+import path from "node:path"
+import { Commands } from "../commands"
+import { Env } from "../../env"
+import { Runtime } from "../../framework/runtime"
+
+export default Runtime.handler(Commands.commands.mini, (input) =>
+  Effect.gen(function* () {
+    const { runMini } = yield* Effect.promise(() => import("../../mini"))
+    const project = Option.getOrUndefined(input.project)
+    const server = Option.getOrUndefined(input.server)
+    const password = yield* Env.password
+    yield* Effect.promise(() =>
+      runMini({
+        attach: server,
+        password: password ? Redacted.value(password) : undefined,
+        directory:
+          server !== undefined
+            ? project
+            : project === undefined
+              ? process.cwd()
+              : path.resolve(process.env.PWD ?? process.cwd(), project),
+        continue: input.continue,
+        session: Option.getOrUndefined(input.session),
+        fork: input.fork,
+        model: Option.getOrUndefined(input.model),
+        agent: Option.getOrUndefined(input.agent),
+        prompt: Option.getOrUndefined(input.prompt),
+        replay: input.replay,
+        replayLimit: Option.getOrUndefined(input.replayLimit),
+        demo: input.demo,
+      }),
+    )
+  }),
+)

+ 31 - 0
packages/cli/src/commands/handlers/run.ts

@@ -0,0 +1,31 @@
+import { Effect, Option, Redacted } from "effect"
+import { Commands } from "../commands"
+import { Env } from "../../env"
+import { Runtime } from "../../framework/runtime"
+
+export default Runtime.handler(Commands.commands.run, (input) =>
+  Effect.gen(function* () {
+    const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
+    const password = yield* Env.password
+    const separator = process.argv.indexOf("--", 2)
+    yield* Effect.promise(() =>
+      runNonInteractive({
+        message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
+        continue: input.continue,
+        session: Option.getOrUndefined(input.session),
+        fork: input.fork,
+        model: Option.getOrUndefined(input.model),
+        agent: Option.getOrUndefined(input.agent),
+        format: input.format,
+        file: [...input.file],
+        title: Option.getOrUndefined(input.title),
+        server: Option.getOrUndefined(input.server),
+        password: password ? Redacted.value(password) : undefined,
+        directory: Option.getOrUndefined(input.dir),
+        variant: Option.getOrUndefined(input.variant),
+        thinking: input.thinking,
+        dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions,
+      }),
+    )
+  }),
+)

+ 8 - 146
packages/cli/src/commands/handlers/serve.ts

@@ -1,154 +1,16 @@
-import { NodeHttpServer } from "@effect/platform-node"
-import { Credential } from "@opencode-ai/core/credential"
-import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
-import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { PermissionSaved } from "@opencode-ai/core/permission/saved"
-import { Project } from "@opencode-ai/core/project"
-import { Global } from "@opencode-ai/core/global"
-import { Context, Effect, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect"
-import { HttpRouter, HttpServer } from "effect/unstable/http"
-import { createServer } from "node:http"
-import { createRoutes } from "@opencode-ai/server/routes"
-import { ServerAuth } from "@opencode-ai/server/auth"
-import { InstallationVersion } from "@opencode-ai/core/installation/version"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { Effect, Option } from "effect"
 import { Commands } from "../commands"
 import { Runtime } from "../../framework/runtime"
-import { Service } from "@opencode-ai/client/effect"
-import { Env } from "../../env"
-import { ServiceConfig } from "../../services/service-config"
-import { Updater } from "../../services/updater"
-import { randomBytes, randomUUID } from "crypto"
-import path from "path"
+import { ServerProcess } from "../../server-process"
 
 export default Runtime.handler(
   Commands.commands.serve,
   Effect.fn("cli.serve")(function* (input) {
-    if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home))
-    return yield* Effect.scoped(
-      Effect.gen(function* () {
-        const standalonePassword = yield* Env.password
-        // Keep the lease credential out of the environment inherited by any
-        // process this server spawns.
-        if (input.stdio) {
-          delete process.env.OPENCODE_PASSWORD
-          delete process.env.OPENCODE_SERVER_PASSWORD
-        }
-        const config = input.service ? yield* ServiceConfig.read() : {}
-        const password = input.service
-          ? yield* ServiceConfig.password()
-          : standalonePassword
-            ? Redacted.value(standalonePassword)
-            : randomBytes(32).toString("base64url")
-        if (!password) return yield* Effect.fail(new Error("Missing server password"))
-        const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
-        const port = Option.isSome(input.port)
-          ? input.port
-          : config.port === undefined
-            ? Option.none<number>()
-            : Option.some(config.port)
-        const address = yield* listen(hostname, port, password)
-        yield* Effect.tryPromise(() =>
-          createOpencodeClient({
-            baseUrl: HttpServer.formatAddress(address),
-            headers: ServerAuth.headers({ password }),
-          }).v2.health.get({}),
-        )
-        if (input.service) yield* register(address)
-        const url = HttpServer.formatAddress(address)
-        console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
-        if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
-        const updater = yield* Updater.Service
-        yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
-        return yield* input.stdio ? waitForStdinClose() : Effect.never
-      }).pipe(Effect.annotateLogs({ role: "server" })),
-    )
+    if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
+    return yield* ServerProcess.run({
+      mode: input.service ? "service" : input.stdio ? "stdio" : "default",
+      hostname: Option.getOrUndefined(input.hostname),
+      port: Option.getOrUndefined(input.port),
+    })
   }),
 )
-
-// Server-side half of the registration protocol. The registration embeds the
-// password so the file alone is enough for any client to discover and
-// authenticate. The file arbitrates ownership after concurrent starts; it is
-// not a startup lock: the atomic rename elects the latest writer, the watcher
-// self-evicts losers, and the finalizer id-guard keeps an exiting server from
-// deleting its successor's registration.
-// Written and read through Service.Info so the file the server registers is
-// provably the contract clients discover with.
-const infoJson = Schema.fromJsonString(Service.Info)
-const encodeInfo = Schema.encodeEffect(infoJson)
-const decodeInfo = Schema.decodeUnknownEffect(infoJson)
-
-const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
-  const fs = yield* FileSystem.FileSystem
-  const { file } = yield* ServiceConfig.options()
-  const id = randomUUID()
-  const secret = yield* ServiceConfig.password()
-  const temp = file + "." + id + ".tmp"
-  yield* fs.makeDirectory(path.dirname(file), { recursive: true })
-  const encoded = yield* encodeInfo({
-    id,
-    version: InstallationVersion,
-    url: HttpServer.formatAddress(address),
-    pid: process.pid,
-    password: secret,
-  })
-  yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
-  yield* fs.rename(temp, file)
-  const currentID = fs.readFileString(file).pipe(
-    Effect.flatMap(decodeInfo),
-    Effect.map((info) => info.id),
-    Effect.orElseSucceed(() => undefined),
-  )
-  yield* currentID.pipe(
-    Effect.flatMap((current) =>
-      current === id
-        ? Effect.void
-        : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
-    ),
-    Effect.repeat(Schedule.spaced("10 seconds")),
-    Effect.forkScoped,
-  )
-  yield* Effect.addFinalizer(() =>
-    currentID.pipe(
-      Effect.flatMap((current) => (current === id ? fs.remove(file) : Effect.void)),
-      Effect.ignore,
-    ),
-  )
-})
-
-function waitForStdinClose() {
-  return Effect.callback<void>((resume) => {
-    const close = () => resume(Effect.void)
-    process.stdin.once("end", close)
-    process.stdin.once("close", close)
-    process.stdin.resume()
-    if (process.stdin.readableEnded || process.stdin.destroyed) close()
-    return Effect.sync(() => {
-      process.stdin.off("end", close)
-      process.stdin.off("close", close)
-      process.stdin.pause()
-    })
-  })
-}
-
-function listen(hostname: string, port: Option.Option<number>, password: string) {
-  if (Option.isSome(port)) return bind(hostname, port.value, password)
-  const next = (port: number): ReturnType<typeof bind> =>
-    bind(hostname, port, password).pipe(
-      Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
-    )
-  return next(4096)
-}
-
-function bind(hostname: string, port: number, password: string) {
-  const server = createServer()
-  return Layer.build(
-    HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
-      Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
-      Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
-    ),
-  ).pipe(
-    Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
-    Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
-  )
-}

+ 70 - 0
packages/cli/src/daemon.ts

@@ -0,0 +1,70 @@
+import { Service } from "@opencode-ai/client/effect"
+import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import { ServerAuth } from "@opencode-ai/server/auth"
+import { Effect } from "effect"
+import { ServiceConfig } from "./services/service-config"
+
+export type SharedOptions = {
+  readonly mode: "shared"
+  readonly command?: ReadonlyArray<string>
+}
+export type AttachOptions = {
+  readonly mode: "attach"
+  readonly url: string
+  readonly username?: string
+  readonly password?: string
+}
+export type Options = SharedOptions | AttachOptions
+
+const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) {
+  const transport = {
+    url: options.url,
+    headers:
+      options.password === undefined
+        ? undefined
+        : ServerAuth.headers({ password: options.password, username: options.username }),
+  } satisfies Service.Transport
+  const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
+  const health = yield* Effect.tryPromise({
+    try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
+    catch: (cause) => attachError(options, cause),
+  })
+  if (health.version !== InstallationVersion)
+    return yield* Effect.fail(
+      new Error(`Server at ${options.url} has version ${health.version}; this client requires ${InstallationVersion}`),
+    )
+  return transport
+})
+
+const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) {
+  const config = yield* ServiceConfig.options()
+  const service = options.command === undefined ? config : { ...config, command: options.command }
+  const found = yield* Service.discover(service)
+  if (found) return found
+  return yield* Service.start(service)
+})
+
+export function transport(options: AttachOptions): ReturnType<typeof attach>
+export function transport(options: SharedOptions): ReturnType<typeof shared>
+export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
+export function transport(options: Options) {
+  if (options.mode === "attach") return attach(options)
+  return shared(options)
+}
+
+function attachError(options: AttachOptions, cause: unknown) {
+  if (isUnauthorizedError(cause)) {
+    return new Error(
+      options.password === undefined
+        ? `Server at ${options.url} requires authentication; provide a password`
+        : `Server at ${options.url} rejected the supplied credentials`,
+      { cause },
+    )
+  }
+  if (cause instanceof ClientError && cause.reason === "Transport")
+    return new Error(`Could not reach server at ${options.url}`, { cause })
+  return new Error(`Server at ${options.url} did not provide a compatible V2 health response`, { cause })
+}
+
+export * as Daemon from "./daemon"

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

@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
     logout: () => import("./commands/handlers/mcp/logout"),
   },
   migrate: () => import("./commands/handlers/migrate"),
+  mini: () => import("./commands/handlers/mini"),
+  run: () => import("./commands/handlers/run"),
   service: {
     start: () => import("./commands/handlers/service/start"),
     restart: () => import("./commands/handlers/service/restart"),

+ 36 - 31
packages/opencode/src/cli/cmd/run/catalog.shared.ts → packages/cli/src/mini/catalog.shared.ts

@@ -1,13 +1,18 @@
-import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import type {
+  AgentListOutput,
+  CommandListOutput,
+  ModelListOutput,
+  OpenCodeClient,
+  ProviderListOutput,
+  SkillListOutput,
+} from "@opencode-ai/client/promise"
 import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
 
-type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
-type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
-type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
-type CurrentProvider = NonNullable<
-  Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]
->["data"][number]
-type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
+type CurrentAgent = AgentListOutput["data"][number]
+type CurrentCommand = CommandListOutput["data"][number]
+type CurrentSkill = SkillListOutput["data"][number]
+type CurrentProvider = ProviderListOutput["data"][number]
+type CurrentModel = ModelListOutput["data"][number]
 
 function location(directory: string) {
   return {
@@ -91,16 +96,16 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
 // For explicit --model flows, wait for that exact ref to appear before prompt
 // admission. On timeout, return and let the real execution error surface.
 export async function waitForCatalogReady(input: {
-  sdk: OpencodeClient
+  sdk: OpenCodeClient
   directory: string
   model: { providerID: string; modelID: string }
   timeoutMs?: number
 }) {
   const deadline = Date.now() + (input.timeoutMs ?? 5_000)
   while (Date.now() < deadline) {
-    const models = await input.sdk.v2.model
-      .list(location(input.directory), { throwOnError: true })
-      .then((result) => result.data?.data ?? [])
+    const models = await input.sdk.model
+      .list(location(input.directory))
+      .then((result) => result.data)
       .catch(() => undefined)
     if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
     await new Promise((resolve) => setTimeout(resolve, 25))
@@ -108,47 +113,47 @@ export async function waitForCatalogReady(input: {
 }
 
 export async function waitForDefaultModel(input: {
-  sdk: OpencodeClient
+  sdk: OpenCodeClient
   directory: string
   timeoutMs?: number
   active?: () => boolean
 }): Promise<{ providerID: string; modelID: string } | undefined> {
   const deadline = Date.now() + (input.timeoutMs ?? 5_000)
   while (Date.now() < deadline && (input.active?.() ?? true)) {
-    const model = await input.sdk.v2.model
-      .default(location(input.directory), { throwOnError: true })
-      .then((result) => result.data?.data)
+    const model = await input.sdk.model
+      .default(location(input.directory))
+      .then((result) => result.data)
       .catch(() => undefined)
     if (model) return { providerID: model.providerID, modelID: model.id }
     await new Promise((resolve) => setTimeout(resolve, 25))
   }
 }
 
-export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
-  const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
-  return (result.data?.data ?? []).map(runAgent)
+export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
+  const result = await sdk.agent.list(location(directory))
+  return result.data.map(runAgent)
 }
 
-export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
+export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
   const [commands, skills] = await Promise.all([
-    sdk.v2.command.list(location(directory), { throwOnError: true }),
-    sdk.v2.skill.list(location(directory), { throwOnError: true }),
+    sdk.command.list(location(directory)),
+    sdk.skill.list(location(directory)),
   ])
   return [
-    ...(commands.data?.data ?? []).map(runCommand),
-    ...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
+    ...commands.data.map(runCommand),
+    ...skills.data.filter((skill) => skill.slash !== false).map(runSkill),
   ]
 }
 
-export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
-  const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
-  return (result.data?.data ?? []).filter((reference) => !reference.hidden)
+export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
+  const result = await sdk.reference.list(location(directory))
+  return result.data.filter((reference) => !reference.hidden)
 }
 
-export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
+export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
   const [providers, models] = await Promise.all([
-    sdk.v2.provider.list(location(directory), { throwOnError: true }),
-    sdk.v2.model.list(location(directory), { throwOnError: true }),
+    sdk.provider.list(location(directory)),
+    sdk.model.list(location(directory)),
   ])
-  return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
+  return runProviders([...providers.data], [...models.data])
 }

+ 1 - 1
packages/opencode/src/cli/cmd/run/demo.ts → packages/cli/src/mini/demo.ts

@@ -678,7 +678,7 @@ function emitTask(state: State): void {
     state: {
       status: "running",
       input: {
-        filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
+        filePath: "packages/cli/src/mini/stream.ts",
         offset: 1,
         limit: 200,
       },

+ 0 - 0
packages/opencode/src/cli/cmd/run/entry.body.ts → packages/cli/src/mini/entry.body.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/footer.command.tsx → packages/cli/src/mini/footer.command.tsx


+ 1 - 1
packages/opencode/src/cli/cmd/run/footer.menu.tsx → packages/cli/src/mini/footer.menu.tsx

@@ -3,7 +3,7 @@ import { TextAttributes, type ColorInput } from "@opentui/core"
 import { useTerminalDimensions } from "@opentui/solid"
 import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
 import { transparent, type RunFooterTheme } from "./theme"
-import * as Locale from "@/util/locale"
+import { Locale } from "@opencode-ai/tui/util/locale"
 
 export const FOOTER_MENU_ROWS = 8
 

+ 0 - 0
packages/opencode/src/cli/cmd/run/footer.permission.tsx → packages/cli/src/mini/footer.permission.tsx


+ 6 - 3
packages/opencode/src/cli/cmd/run/footer.prompt.tsx → packages/cli/src/mini/footer.prompt.tsx

@@ -12,7 +12,7 @@ import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
 import fuzzysort from "fuzzysort"
 import path from "path"
 import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
-import * as Locale from "@/util/locale"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import {
   createPromptHistory,
   displayCharAt,
@@ -67,7 +67,7 @@ type PromptInput = {
   prompt: Accessor<boolean>
   width: Accessor<number>
   theme: Accessor<RunFooterTheme>
-  history?: RunPrompt[]
+  history?: Accessor<RunPrompt[]>
   onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
   onCycle: () => void
   onInterrupt: () => boolean
@@ -302,7 +302,10 @@ export function createPromptState(input: PromptInput): PromptState {
     return new StyledText([fg(input.theme().muted)('Ask anything... "Fix a TODO in the codebase"')])
   })
 
-  let history = createPromptHistory(input.history)
+  let history = createPromptHistory(input.history?.())
+  createEffect(() => {
+    history = createPromptHistory(input.history?.())
+  })
   let draft: RunPrompt = { text: "", parts: [] }
   let stash: RunPrompt = { text: "", parts: [] }
   let area: TextareaRenderable | undefined

+ 0 - 0
packages/opencode/src/cli/cmd/run/footer.question.tsx → packages/cli/src/mini/footer.question.tsx


+ 0 - 0
packages/opencode/src/cli/cmd/run/footer.subagent.tsx → packages/cli/src/mini/footer.subagent.tsx


+ 11 - 1
packages/opencode/src/cli/cmd/run/footer.ts → packages/cli/src/mini/footer.ts

@@ -203,6 +203,8 @@ export class RunFooter implements FooterApi {
   private setSubagent: (next: FooterSubagentState) => void
   private queuedPrompts: Accessor<FooterQueuedPrompt[]>
   private setQueuedPrompts: Setter<FooterQueuedPrompt[]>
+  private history: Accessor<RunPrompt[]>
+  private setHistory: Setter<RunPrompt[]>
   private promptRoute: FooterPromptRoute = { type: "composer" }
   private subagentMenuRows = SUBAGENT_ROWS
   private autocomplete = false
@@ -289,6 +291,9 @@ export class RunFooter implements FooterApi {
     const [queuedPrompts, setQueuedPrompts] = createSignal<FooterQueuedPrompt[]>([])
     this.queuedPrompts = queuedPrompts
     this.setQueuedPrompts = setQueuedPrompts
+    const [history, setHistory] = createSignal(options.history ?? [])
+    this.history = history
+    this.setHistory = setHistory
     this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
     this.scrollback = this.createScrollback(options.wrote ?? false)
 
@@ -322,7 +327,7 @@ export class RunFooter implements FooterApi {
               diffStyle: options.diffStyle,
               tuiConfig: options.tuiConfig,
               backgroundSubagents: options.backgroundSubagents,
-              history: options.history,
+              history: footer.history,
               agent: options.agentLabel,
               onSubmit: footer.handlePrompt,
               onPermissionReply: footer.handlePermissionReply,
@@ -390,6 +395,11 @@ export class RunFooter implements FooterApi {
   }
 
   public event(next: FooterEvent): void {
+    if (next.type === "history") {
+      this.setHistory(next.history)
+      return
+    }
+
     if (next.type === "model") {
       this.setCurrentModel(next.selection)
     }

+ 1 - 1
packages/opencode/src/cli/cmd/run/footer.view.tsx → packages/cli/src/mini/footer.view.tsx

@@ -88,7 +88,7 @@ type RunFooterViewProps = {
   diffStyle?: RunDiffStyle
   tuiConfig: RunTuiConfig
   backgroundSubagents: boolean
-  history?: RunPrompt[]
+  history?: () => RunPrompt[]
   agent: string
   onSubmit: (input: RunPrompt) => boolean
   onPermissionReply: (input: PermissionReply) => void | Promise<void>

+ 0 - 0
packages/opencode/src/cli/cmd/run/footer.width.ts → packages/cli/src/mini/footer.width.ts


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

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

+ 234 - 0
packages/cli/src/mini/mini.ts

@@ -0,0 +1,234 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
+import { truthy } from "@opencode-ai/core/flag/flag"
+import { Global } from "@opencode-ai/core/global"
+import { Effect } from "effect"
+import path from "node:path"
+import { Daemon } from "../daemon"
+import { waitForCatalogReady } from "./catalog.shared"
+import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
+import type { RunInput, RunTuiConfig } from "./types"
+
+export type MiniCommandInput = {
+  directory?: string
+  attach?: string
+  password?: string
+  username?: string
+  continue?: boolean
+  session?: string
+  fork?: boolean
+  model?: string
+  agent?: string
+  prompt?: string
+  replay?: boolean
+  replayLimit?: number
+  demo?: boolean
+  serverCommand?: ReadonlyArray<string>
+  tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
+}
+
+type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
+type Transport = { readonly url: string; readonly headers?: HeadersInit }
+
+export async function runMini(input: MiniCommandInput) {
+  validate(input)
+  const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
+  const runtimeTask = import("./runtime")
+  const directory = input.attach ? input.directory : localDirectory(input.directory)
+  const transportTask = startTransport(input)
+  void transportTask.catch(() => {})
+
+  try {
+    if (input.attach) await transportTask
+    const sdk = OpenCode.make({
+      baseUrl: "http://opencode.pending",
+      fetch: deferredFetch(transportTask),
+    })
+    const attachedSession =
+      input.attach && input.session && !input.directory
+        ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
+        : undefined
+    const resolvedDirectory =
+      directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk))
+    const model = parseModel(input.model)
+    let agentTask: Promise<string | undefined> | undefined
+    const resolveAgent = () => {
+      agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
+      return agentTask
+    }
+    const resolveSession = async () => {
+      const [agent, selected] = await Promise.all([
+        resolveAgent(),
+        selectSession(sdk, resolvedDirectory, input, attachedSession),
+      ])
+      const readyModel =
+        model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
+      if (readyModel) await waitForCatalogReady({ sdk, directory: resolvedDirectory, model: readyModel })
+      const session = selected ?? (await createSession(sdk, resolvedDirectory, agent, model))
+      return { id: session.id, title: session.title, resume: selected !== undefined }
+    }
+    const create = (
+      _sdk: OpenCodeClient,
+      next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
+    ) => createSession(sdk, resolvedDirectory, next.agent, next.model, next.variant)
+    const runtime = await runtimeTask
+    await runtime.runInteractiveDeferredMode({
+      sdk,
+      directory: resolvedDirectory,
+      resolveAgent,
+      session: resolveSession,
+      createSession: create,
+      agent: input.agent,
+      model,
+      variant: undefined,
+      files: [],
+      initialInput,
+      thinking: true,
+      backgroundSubagents:
+        truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") ||
+        (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")),
+      replay: input.replay ?? true,
+      replayLimit: input.replayLimit,
+      demo: input.demo,
+      tuiConfig: input.tuiConfig,
+    })
+  } catch (error) {
+    if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
+    throw error
+  }
+}
+
+/** @internal Exported for testing. */
+export function mergeInput(piped: string | undefined, prompt: string | undefined) {
+  if (!prompt) return piped || undefined
+  if (!piped) return prompt
+  return piped + "\n" + prompt
+}
+
+function validate(input: MiniCommandInput) {
+  if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
+  if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
+    fail("--replay-limit must be a positive integer")
+  }
+  if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
+  resolveInteractiveStdin().cleanup?.()
+}
+
+function localDirectory(directory?: string): string {
+  const root = process.env.PWD ?? process.cwd()
+  try {
+    process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
+    return process.cwd()
+  } catch {
+    fail(`Failed to change directory to ${directory}`)
+  }
+}
+
+function startTransport(input: MiniCommandInput): Promise<Transport> {
+  if (input.attach) {
+    return Effect.runPromise(
+      Daemon.transport({
+        mode: "attach",
+        url: input.attach,
+        password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
+        username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
+      }),
+    )
+  }
+  return Effect.runPromise(
+    Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe(
+      Effect.provide(NodeFileSystem.layer),
+      Effect.provide(Global.layerWith({})),
+    ),
+  )
+}
+
+function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch {
+  const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+    const transport = await transportTask
+    const request = new Request(input, init)
+    const source = new URL(request.url)
+    const headers = new Headers(request.headers)
+    for (const [key, value] of new Headers(transport.headers)) headers.set(key, value)
+    return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers })
+  }
+  return fetch as typeof globalThis.fetch
+}
+
+async function remoteDirectory(
+  transport: { url: string; headers?: HeadersInit },
+  sdk: OpenCodeClient,
+): Promise<string> {
+  const location = await sdk.location.get()
+  if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`)
+  return location.directory
+}
+
+function parseModel(value?: string): RunInput["model"] {
+  if (!value) return
+  const [providerID, ...rest] = value.split("/")
+  const modelID = rest.join("/")
+  if (!providerID || !modelID) fail("--model must use the format provider/model")
+  return { providerID, modelID }
+}
+
+async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
+  if (!name) return
+  const deadline = Date.now() + 5_000
+  let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
+  while (Date.now() < deadline) {
+    agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
+    const agent = agents?.data.find((item) => item.id === name)
+    if (agent?.mode === "subagent") {
+      warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
+      return
+    }
+    if (agent) return name
+    await Bun.sleep(25)
+  }
+  if (!agents) {
+    warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
+    return
+  }
+  warning(`agent "${name}" not found. Falling back to default agent`)
+}
+
+async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
+  const selected =
+    preselected ??
+    (input.session
+      ? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
+      : input.continue
+        ? await sdk.session
+            .list({ directory, parentID: null, limit: 1, order: "desc" })
+            .then((result) => result.data[0])
+        : undefined)
+  if (input.session && !selected) fail("Session not found")
+  if (!selected) return
+  if (!input.fork) return selected
+  return sdk.session.fork({ sessionID: selected.id })
+}
+
+async function createSession(
+  sdk: OpenCodeClient,
+  directory: string,
+  agent: string | undefined,
+  model: RunInput["model"],
+  variant?: string,
+): Promise<Session> {
+  if (model) await waitForCatalogReady({ sdk, directory, model })
+  return sdk.session.create({
+    agent,
+    model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
+    location: { directory },
+  })
+}
+
+function warning(message: string) {
+  process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
+}
+
+function fail(message: string): never {
+  process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
+  process.exit(1)
+}

+ 34 - 40
packages/opencode/src/cli/cmd/run/noninteractive.ts → packages/cli/src/mini/noninteractive.ts

@@ -1,15 +1,17 @@
 import type {
-  OpencodeClient,
+  EventSubscribeOutput,
+  OpenCodeClient,
+} from "@opencode-ai/client/promise"
+import type {
   ReasoningPart,
   StepFinishPart,
   StepStartPart,
   TextPart,
   ToolPart,
-  V2Event,
 } from "@opencode-ai/sdk/v2"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { EOL } from "node:os"
-import { MessageID } from "@/session/schema"
-import { UI } from "../../ui"
+import { UI } from "./ui"
 
 type Model = {
   providerID: string
@@ -23,7 +25,7 @@ type File = {
 }
 
 type Input = {
-  client: OpencodeClient
+  client: OpenCodeClient
   sessionID: string
   message: string
   files: File[]
@@ -52,6 +54,7 @@ type ToolState = StartedPart & {
   provider?: unknown
 }
 
+type V2Event = EventSubscribeOutput
 type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
 
 // MCP elicitations are temporarily owned by the "global" sentinel instead of a real
@@ -61,16 +64,11 @@ const GLOBAL_FORM_SESSION_ID = "global"
 
 export async function runNonInteractivePrompt(input: Input) {
   const controller = new AbortController()
-  const events = await input.client.v2.event.subscribe({
-    signal: controller.signal,
-    sseMaxRetryAttempts: 0,
-    throwOnError: true,
-  })
-  const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
+  const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
   const connected = await stream.next()
   if (connected.done) throw new Error("Event stream disconnected before prompt admission")
 
-  const messageID = MessageID.ascending()
+  const messageID = SessionMessage.ID.create()
   const starts = new Map<string, StartedPart>()
   const tools = new Map<string, ToolState>()
   let submitted = false
@@ -101,7 +99,7 @@ export async function runNonInteractivePrompt(input: Input) {
     UI.empty()
   }
 
-  const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
+  const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
     if (!input.dangerouslySkipPermissions) {
       permissionRejected = true
       UI.println(
@@ -110,7 +108,7 @@ export async function runNonInteractivePrompt(input: Input) {
           `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
       )
     }
-    await input.client.v2.session.permission
+    await input.client.permission
       .reply({
         sessionID: input.sessionID,
         requestID: request.id,
@@ -118,18 +116,18 @@ export async function runNonInteractivePrompt(input: Input) {
       })
       .catch(() => {})
     if (!input.dangerouslySkipPermissions) {
-      await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+      await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
     }
   }
 
   const rejectQuestion = async (request: { id: string }) => {
     questionRejected = true
-    await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
+    await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
   }
 
   const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
     formCancelled = true
-    await input.client.v2.session.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
+    await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
   }
 
   const consume = async () => {
@@ -156,7 +154,7 @@ export async function runNonInteractivePrompt(input: Input) {
         continue
       }
       if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
-      const time = toMillis(event.created)
+      const time = toMillis("created" in event ? event.created : undefined)
 
       if (event.type === "session.prompt.promoted") {
         if (event.data.inputID === messageID) {
@@ -367,34 +365,31 @@ export async function runNonInteractivePrompt(input: Input) {
     interrupted = true
     process.exitCode = 130
     admission?.abort()
-    void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+    void input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
   }
   process.on("SIGINT", interrupt)
 
   let completed: Promise<void> | undefined
   try {
     if (input.agent) {
-      await input.client.v2.session.switchAgent(
-        { sessionID: input.sessionID, agent: input.agent },
-        { throwOnError: true },
-      )
+      await input.client.session.switchAgent({ sessionID: input.sessionID, agent: input.agent })
     }
     const selected = input.model
       ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
       : input.variant
-        ? await input.client.v2.session
-            .get({ sessionID: input.sessionID }, { throwOnError: true })
-            .then((result) => result.data.data.model)
+        ? await input.client.session
+            .get({ sessionID: input.sessionID })
+            .then((result) => result.model)
             .then(async (model) => {
               if (model) return { ...model, variant: input.variant }
-              const result = await input.client.v2.model.default(undefined, { throwOnError: true })
-              const fallback = result.data.data
+              const result = await input.client.model.default()
+              const fallback = result.data
               return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
             })
         : undefined
     if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
     if (selected) {
-      await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
+      await input.client.session.switchModel({ sessionID: input.sessionID, model: selected })
     }
 
     const prepared = await Promise.all(input.files.map(prepareFile))
@@ -402,7 +397,7 @@ export async function runNonInteractivePrompt(input: Input) {
     submitted = true
     completed = consume()
     admission = new AbortController()
-    const response = await input.client.v2.session
+    const response = await input.client.session
       .prompt(
         {
           sessionID: input.sessionID,
@@ -413,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
           },
           delivery: "steer",
         },
-        { throwOnError: true, signal: admission.signal },
+        { signal: admission.signal },
       )
       .catch(async (error) => {
         if (interrupted) {
-          await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+          await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
         }
         controller.abort()
         await completed?.catch(() => {})
@@ -426,22 +421,21 @@ export async function runNonInteractivePrompt(input: Input) {
       })
     admission = undefined
     if (!response) return
-    if (!response.data.data) throw new Error("Prompt was not admitted")
-    if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+    if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
 
     const [permissions, questions, forms] = await Promise.all([
-      input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
-      input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
+      input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
+      input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
       Promise.all(
         (input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
-          input.client.v2.session.form.list({ sessionID }).catch(() => undefined),
+          input.client.form.list({ sessionID }).catch(() => undefined),
         ),
       ),
     ])
     await Promise.all([
-      ...(permissions?.data?.data ?? []).map(replyPermission),
-      ...(questions?.data?.data ?? []).map(rejectQuestion),
-      ...forms.flatMap((response) => response?.data?.data ?? []).map(cancelForm),
+      ...(permissions ?? []).map(replyPermission),
+      ...(questions ?? []).map(rejectQuestion),
+      ...forms.flatMap((response) => response ?? []).map(cancelForm),
     ])
     await completed
   } finally {

+ 0 - 0
packages/opencode/src/cli/cmd/run/permission.shared.ts → packages/cli/src/mini/permission.shared.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/prompt.editor.ts → packages/cli/src/mini/prompt.editor.ts


+ 1 - 1
packages/opencode/src/cli/cmd/run/prompt.shared.ts → packages/cli/src/mini/prompt.shared.ts

@@ -7,7 +7,7 @@
 // the current browse position. When the user arrows up at cursor offset 0,
 // the current draft is saved and history begins. Arrowing past the end
 // restores the draft.
-export { displayCharAt, displaySlice, mentionTriggerIndex } from "../prompt-display"
+export { displayCharAt, displaySlice, mentionTriggerIndex } from "@opencode-ai/tui/prompt/display"
 import type { RunPrompt } from "./types"
 
 const HISTORY_LIMIT = 200

+ 0 - 0
packages/opencode/src/cli/cmd/run/question.shared.ts → packages/cli/src/mini/question.shared.ts


+ 308 - 0
packages/cli/src/mini/run.ts

@@ -0,0 +1,308 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
+import { Global } from "@opencode-ai/core/global"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { Effect } from "effect"
+import { open } from "node:fs/promises"
+import path from "node:path"
+import { Daemon } from "../daemon"
+import { Standalone } from "../services/standalone"
+import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared"
+import { runNonInteractivePrompt } from "./noninteractive"
+import { toolInlineInfo } from "./tool"
+import { UI } from "./ui"
+
+export type RunCommandInput = {
+  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>
+}
+
+type FilePart = {
+  url: string
+  filename: string
+  mime: string
+}
+
+type Transport = { readonly url: string; readonly headers?: HeadersInit }
+
+type Prepared = {
+  directory?: string
+  message: string
+  files: FilePart[]
+}
+
+const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
+
+export async function runNonInteractive(input: RunCommandInput) {
+  if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
+  const root = process.env.PWD ?? process.cwd()
+  const directory = input.server ? input.directory : localDirectory(input.directory, root)
+  const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await Bun.stdin.text())
+  if (!message?.trim()) fail("You must provide a message")
+  const files = await Promise.all(
+    input.file.map((file) => prepareFile(file, input.server ? root : (directory ?? root), input.server !== undefined)),
+  )
+  const prepared = { directory, message, files }
+  if (input.standaloneCommand)
+    return Effect.runPromise(
+      Effect.scoped(
+        Effect.gen(function* () {
+          const transport = yield* Standalone.transport({ command: input.standaloneCommand })
+          yield* Effect.promise(() => execute(input, prepared, transport))
+        }),
+      ),
+    )
+  const transport = await startTransport(input)
+  return execute(input, prepared, transport)
+}
+
+async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) {
+  const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
+  const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
+  if (!requestedDirectory) fail("Failed to resolve server directory")
+  const session = await selectSession(client, requestedDirectory, input)
+  const cwd = session?.location.directory ?? requestedDirectory
+  const explicitModel = parseModel(input.model)
+  const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
+  const defaultModel =
+    input.variant && !explicitModel && !sessionModel
+      ? await waitForDefaultModel({ sdk: client, directory: cwd })
+      : undefined
+  const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
+  if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
+  if (model) {
+    await waitForCatalogReady({ sdk: client, directory: cwd, model })
+    const available = await client.model.list({ location: { directory: cwd } })
+    if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
+      return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
+  }
+  const agent = await validateAgent(client, cwd, input.agent, input.server)
+  const selected =
+    session ??
+    (await client.session.create({
+      agent,
+      model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined,
+      location: { directory: cwd },
+    }))
+  if (!session && input.title !== undefined) {
+    await client.session.rename({
+      sessionID: selected.id,
+      title:
+        input.title ||
+        prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
+    })
+  }
+
+  try {
+    await runNonInteractivePrompt({
+      client,
+      sessionID: selected.id,
+      message: prepared.message,
+      files: prepared.files,
+      agent,
+      model,
+      variant: input.variant,
+      thinking: input.thinking ?? false,
+      format: input.format,
+      dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
+      attached: !input.standaloneCommand,
+      renderTool,
+      renderToolError,
+    })
+  } catch (error) {
+    UI.error(error instanceof Error ? error.message : String(error))
+    process.exitCode = 1
+  }
+}
+
+export function mergeInput(message: string | undefined, piped: string | undefined) {
+  if (!message) return piped || undefined
+  if (!piped) return message
+  return message + "\n" + piped
+}
+
+export function pickRunModel(
+  explicit: { providerID: string; modelID: string } | undefined,
+  variant: string | undefined,
+  session: { providerID: string; modelID: string } | undefined,
+  fallback: { providerID: string; modelID: string } | undefined,
+) {
+  if (explicit) return explicit
+  if (!variant) return
+  return session ?? fallback
+}
+
+function formatMessage(message: string[]) {
+  const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
+  return value || undefined
+}
+
+function localDirectory(directory: string | undefined, root: string) {
+  try {
+    process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
+    return process.cwd()
+  } catch {
+    fail(`Failed to change directory to ${directory}`)
+  }
+}
+
+function startTransport(input: RunCommandInput) {
+  if (input.server) {
+    return Effect.runPromise(
+      Daemon.transport({
+        mode: "attach",
+        url: input.server,
+        password: input.password,
+        username: input.username,
+      }),
+    )
+  }
+  return Effect.runPromise(
+    Daemon.transport({ mode: "shared" }).pipe(
+      Effect.provide(NodeFileSystem.layer),
+      Effect.provide(Global.layerWith({})),
+    ),
+  )
+}
+
+function parseModel(value?: string) {
+  if (!value) return
+  const [providerID, ...rest] = value.split("/")
+  const modelID = rest.join("/")
+  if (!providerID || !modelID) fail("--model must use the format provider/model")
+  return { providerID, modelID }
+}
+
+async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
+  if (!name) return
+  const agents = await loadRunAgents(client, directory).catch(() => undefined)
+  if (!agents) {
+    warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
+    return
+  }
+  const agent = agents.find((item) => item.name === name)
+  if (!agent) {
+    warning(`agent "${name}" not found. Falling back to default agent`)
+    return
+  }
+  if (agent.mode === "subagent") {
+    warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
+    return
+  }
+  return name
+}
+
+async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
+  const selected = input.session
+    ? await client.session.get({ sessionID: input.session }).catch(() => undefined)
+    : input.continue
+      ? await client.session
+          .list({ directory, parentID: null, limit: 1, order: "desc" })
+          .then((result) => result.data[0])
+      : undefined
+  if (input.session && !selected) fail("Session not found")
+  if (!selected || !input.fork) return selected
+  return client.session.fork({ sessionID: selected.id })
+}
+
+async function prepareFile(input: string, directory: string, remote: boolean): 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 (remote && 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))
+    let offset = 0
+    while (offset < content.length) {
+      const read = await handle.read(content, offset, content.length - offset, offset)
+      if (read.bytesRead === 0) break
+      offset += read.bytesRead
+    }
+    const bytes = content.subarray(0, offset)
+    const detected = FSUtil.mimeType(file)
+    const text = bytes.toString("utf8")
+    const mime =
+      detected.startsWith("image/") || detected === "application/pdf"
+        ? detected
+        : !isBinaryContent(bytes) && Buffer.from(text, "utf8").equals(bytes)
+          ? "text/plain"
+          : detected
+    return {
+      url: `data:${mime};base64,${bytes.toString("base64")}`,
+      filename: path.basename(file),
+      mime,
+    }
+  } finally {
+    await handle.close()
+  }
+}
+
+function isBinaryContent(bytes: Uint8Array) {
+  if (bytes.length === 0) return false
+  if (bytes.includes(0)) return true
+  return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
+}
+
+async function renderTool(part: ToolPart) {
+  const info = toolInlineInfo(part)
+  if (info.mode === "block") {
+    UI.empty()
+    UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
+    if (info.body?.trim()) UI.println(info.body)
+    UI.empty()
+    return
+  }
+  UI.println(
+    UI.Style.TEXT_NORMAL + info.icon,
+    UI.Style.TEXT_NORMAL + info.title,
+    info.description ? UI.Style.TEXT_DIM + info.description + UI.Style.TEXT_NORMAL : "",
+  )
+}
+
+async function renderToolError(part: ToolPart) {
+  const info = toolInlineInfo(part)
+  UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
+}
+
+function warning(message: string) {
+  UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
+}
+
+function reportError(input: RunCommandInput, message: string, sessionID?: string) {
+  process.exitCode = 1
+  if (input.format === "json") {
+    process.stdout.write(
+      JSON.stringify({
+        type: "error",
+        timestamp: Date.now(),
+        sessionID: sessionID ?? "",
+        error: { type: "unknown", message },
+      }) + "\n",
+    )
+    return
+  }
+  UI.error(message)
+}
+
+function fail(message: string): never {
+  UI.error(message)
+  process.exit(1)
+}

+ 7 - 40
packages/opencode/src/cli/cmd/run/runtime.boot.ts → packages/cli/src/mini/runtime.boot.ts

@@ -7,12 +7,10 @@
 // none block each other.
 import { Context, Effect, Layer } from "effect"
 import { resolve } from "@opencode-ai/tui/config"
-import { TuiConfig } from "@/config/tui"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
-import { makeRuntime } from "@/effect/run-service"
+import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { loadRunProviders } from "./catalog.shared"
-import { reusePendingTask } from "./runtime.shared"
 import { resolveCurrentSession, sessionHistory } from "./session.shared"
 import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
 import { pickVariant } from "./variant.shared"
@@ -30,7 +28,6 @@ export type SessionInfo = {
   variant: string | undefined
 }
 
-type Config = Awaited<ReturnType<typeof TuiConfig.get>>
 type BootService = {
   readonly resolveModelInfo: (
     sdk: RunInput["sdk"],
@@ -42,18 +39,10 @@ type BootService = {
     sessionID: string,
     model: RunInput["model"],
   ) => Effect.Effect<SessionInfo>
-  readonly resolveRunTuiConfig: () => Effect.Effect<RunTuiConfig>
-  readonly resolveDiffStyle: () => Effect.Effect<RunDiffStyle>
 }
 
-const configTask: { current?: Promise<Config> } = {}
-
 class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
 
-function loadConfig() {
-  return reusePendingTask(configTask, () => TuiConfig.get())
-}
-
 function emptyModelInfo(): ModelInfo {
   return {
     providers: [],
@@ -77,23 +66,9 @@ function defaultRunTuiConfig(): RunTuiConfig {
   }
 }
 
-function runTuiConfig(config: Config | undefined): RunTuiConfig {
-  if (!config) {
-    return defaultRunTuiConfig()
-  }
-
-  return {
-    keybinds: config.keybinds,
-    leader_timeout: config.leader_timeout,
-    diff_style: config.diff_style ?? "auto",
-  }
-}
-
 const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const config = Effect.fn("RunBoot.config")(() => Effect.promise(() => loadConfig().catch(() => undefined)))
-
     const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
       sdk: RunInput["sdk"],
       directory: string,
@@ -147,19 +122,9 @@ const layer = Layer.effect(
       }
     })
 
-    const resolveRunTuiConfig = Effect.fn("RunBoot.resolveRunTuiConfig")(function* () {
-      return runTuiConfig(yield* config())
-    })
-
-    const resolveDiffStyle = Effect.fn("RunBoot.resolveDiffStyle")(function* () {
-      return runTuiConfig(yield* config()).diff_style ?? "auto"
-    })
-
     return Service.of({
       resolveModelInfo,
       resolveSessionInfo,
-      resolveRunTuiConfig,
-      resolveDiffStyle,
     })
   }),
 )
@@ -190,10 +155,12 @@ export async function resolveSessionInfo(
 }
 
 // Reads TUI config once for direct mode keymap setup and display preferences.
-export async function resolveRunTuiConfig(): Promise<RunTuiConfig> {
-  return runtime.runPromise((svc) => svc.resolveRunTuiConfig()).catch(() => defaultRunTuiConfig())
+export async function resolveRunTuiConfig(
+  config?: RunTuiConfig | Promise<RunTuiConfig>,
+): Promise<RunTuiConfig> {
+  return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
 }
 
-export async function resolveDiffStyle(): Promise<RunDiffStyle> {
-  return runtime.runPromise((svc) => svc.resolveDiffStyle()).catch(() => "auto")
+export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
+  return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
 }

+ 2 - 2
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts → packages/cli/src/mini/runtime.lifecycle.ts

@@ -13,8 +13,8 @@ import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWr
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
 import { Global } from "@opencode-ai/core/global"
 import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
-import { isDefaultTitle } from "@/session/title"
-import * as Locale from "@/util/locale"
+import { isDefaultTitle } from "@opencode-ai/tui/util/session"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import { resolveInteractiveStdin } from "./runtime.stdin"
 import { entrySplash, exitSplash, splashMeta } from "./splash"
 import { resolveRunTheme } from "./theme"

+ 6 - 5
packages/opencode/src/cli/cmd/run/runtime.queue.ts → packages/cli/src/mini/runtime.queue.ts

@@ -8,8 +8,9 @@
 // and tracks per-turn wall-clock duration for the footer status line.
 //
 // Resolves when the footer closes and all in-flight work finishes.
-import * as Locale from "@/util/locale"
-import { MessageID, PartID } from "@/session/schema"
+import { ascending } from "@opencode-ai/schema/identifier"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import { isExitCommand, isNewCommand } from "./prompt.shared"
 import type { FooterApi, FooterEvent, FooterQueuedPrompt, RunPrompt } from "./types"
 
@@ -167,7 +168,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
               ? prompt
               : {
                   ...prompt,
-                  messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
+                  messageID: prompt.messageID ?? queued?.messageID ?? SessionMessage.ID.create(),
                 }
           state.active = sent
 
@@ -285,8 +286,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
       !isNewCommand(prompt.text)
     ) {
       const queued: FooterQueuedPrompt = {
-        messageID: MessageID.ascending(),
-        partID: PartID.ascending(),
+        messageID: SessionMessage.ID.create(),
+        partID: "prt_" + ascending(),
         prompt,
       }
       state.queued = [...state.queued, queued]

+ 0 - 0
packages/opencode/src/cli/cmd/run/runtime.shared.ts → packages/cli/src/mini/runtime.shared.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/runtime.stdin.ts → packages/cli/src/mini/runtime.stdin.ts


+ 104 - 71
packages/opencode/src/cli/cmd/run/runtime.ts → packages/cli/src/mini/runtime.ts

@@ -4,7 +4,7 @@
 // and prompt queue together into a single session loop. Two entry points:
 //
 //   runInteractiveMode     -- used when an SDK client already exists (attach mode)
-//   runInteractiveLocalMode -- used for local in-process mode (no server)
+//   runInteractiveDeferredMode -- paints before resolving its session
 //
 // Both delegate to runInteractiveRuntime, which:
 //   1. resolves TUI config, model info, and session history,
@@ -12,15 +12,22 @@
 //   3. starts the stream transport (SDK event subscription), lazily for fresh
 //      local sessions,
 //   4. runs the prompt queue until the footer closes.
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { MessageID } from "@/session/schema"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
 import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
 import { createRuntimeLifecycle } from "./runtime.lifecycle"
 import { trace } from "./trace"
 import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
-import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
+import type {
+  LocalReplayAnchor,
+  LocalReplayRow,
+  RunInput,
+  RunPrompt,
+  RunProvider,
+  RunTuiConfig,
+  StreamCommit,
+} from "./types"
 
 /** @internal Exported for testing */
 export { pickVariant, resolveVariant } from "./variant.shared"
@@ -44,9 +51,7 @@ type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput) => Promis
 type RunRuntimeInput = {
   boot: () => Promise<BootContext>
   afterPaint?: (ctx: BootContext) => Promise<void> | void
-  resolveSession?: (
-    ctx: BootContext,
-  ) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
+  resolveSession?: (ctx: BootContext) => Promise<ResolvedSession>
   createSession?: (ctx: BootContext, input: CreateSessionInput) => Promise<ResolvedSession>
   files: RunInput["files"]
   initialInput?: string
@@ -55,13 +60,14 @@ type RunRuntimeInput = {
   replay?: boolean
   replayLimit?: number
   demo?: RunInput["demo"]
+  tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
 }
 
-type RunLocalInput = {
+type RunDeferredInput = {
+  sdk: RunInput["sdk"]
   directory: string
-  fetch: typeof globalThis.fetch
   resolveAgent: () => Promise<string | undefined>
-  session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
+  session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string; resume?: boolean } | undefined>
   createSession?: CreateSession
   agent: RunInput["agent"]
   model: RunInput["model"]
@@ -73,6 +79,7 @@ type RunLocalInput = {
   replay?: boolean
   replayLimit?: number
   demo?: RunInput["demo"]
+  tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
 }
 
 type StreamTransportModule = Pick<
@@ -96,6 +103,7 @@ type ResolvedSession = {
   sessionID: string
   sessionTitle?: string
   agent?: string | undefined
+  resume?: boolean
 }
 
 function createSessionResolver(fn?: CreateSession) {
@@ -165,9 +173,9 @@ async function resolveExitTitle(
     return undefined
   }
 
-  return ctx.sdk.v2.session
+  return ctx.sdk.session
     .get({ sessionID: state.sessionID })
-    .then((x) => x.data?.data.title)
+    .then((session) => session.title)
     .catch(() => undefined)
 }
 
@@ -180,7 +188,7 @@ async function resolveExitTitle(
 async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
   const start = performance.now()
   const log = trace()
-  const tuiConfigTask = resolveRunTuiConfig()
+  const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig)
   const ctx = await input.boot()
   const sessionTask =
     ctx.resume === true
@@ -247,9 +255,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
   const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
     directory: ctx.directory,
     findFiles: (query) =>
-      ctx.sdk.find
-        .files({ query, directory: ctx.directory })
-        .then((x) => x.data ?? [])
+      ctx.sdk.file
+        .find({ query, type: "file", location: { directory: ctx.directory } })
+        .then((result) => result.data.map((file) => file.path))
         .catch(() => []),
     agents: [],
     references: [],
@@ -257,7 +265,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
     sessionTitle: state.sessionTitle,
     getSessionID: () => state.sessionID,
     first: session.first,
-    history: session.history,
+    history: state.history,
     agent: state.agent,
     model: state.model,
     variant: state.activeVariant,
@@ -269,17 +277,17 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
       }
 
       log?.write("send.permission.reply", next)
-      await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next })
+      await ctx.sdk.permission.reply({ sessionID: state.sessionID, ...next })
     },
     onQuestionReply: async (next) => {
       if (state.demo?.questionReply(next)) {
         return
       }
 
-      await ctx.sdk.v2.session.question.reply({
+      await ctx.sdk.question.reply({
         sessionID: state.sessionID,
         requestID: next.requestID,
-        questionV2Reply: { answers: next.answers ?? [] },
+        answers: next.answers ?? [],
       })
     },
     onQuestionReject: async (next) => {
@@ -287,7 +295,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
         return
       }
 
-      await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
+      await ctx.sdk.question.reject({ sessionID: state.sessionID, ...next })
     },
     onCycleVariant: () => {
       if (!state.model || state.variants.length === 0) {
@@ -369,7 +377,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
       void (
         state.stream
           ? state.stream.then((item) => item.handle.interruptActiveTurn())
-          : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })
+          : ctx.sdk.session.interrupt({ sessionID: state.sessionID })
       )
         .catch(() => {})
         .finally(() => {
@@ -383,11 +391,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
       }
 
       log?.write("send.background", { sessionID: state.sessionID })
-      void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {})
+      void ctx.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
     },
     onSubagentInterrupt: (sessionID) => {
       log?.write("send.subagent.interrupt", { sessionID })
-      void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {})
+      void ctx.sdk.session.interrupt({ sessionID }).catch(() => {})
     },
     onSubagentSelect: (sessionID) => {
       state.selectSubagent?.(sessionID)
@@ -398,7 +406,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
   })
   const footer = shell.footer
   const firstPaint = footer.idle().catch(() => {})
-  const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel()))
   const ensureSession = () => {
     if (!input.resolveSession || state.sessionID) {
       return Promise.resolve()
@@ -408,13 +415,33 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
       return state.session
     }
 
-    state.session = input.resolveSession(ctx).then((next) => {
+    state.session = input.resolveSession(ctx).then(async (next) => {
       state.sessionID = next.sessionID
       state.sessionTitle = next.sessionTitle ?? state.sessionTitle
       state.agent = next.agent
+      if (!next.resume) return
+      const resumed = await resolveSessionInfo(ctx.sdk, next.sessionID, ctx.model)
+      session.first = resumed.first
+      session.history = resumed.history
+      session.model = resumed.model
+      session.variant = resumed.variant
+      state.shown = !resumed.first
+      state.history = [...resumed.history]
+      state.model = ctx.model ?? resumed.model
+      const resumedSavedVariant = state.model ? await resolveSavedVariant(state.model) : undefined
+      state.activeVariant = resolveVariant(ctx.variant, resumed.variant, resumedSavedVariant, [])
+      session.variant = state.activeVariant
+      footer.event({ type: "history", history: resumed.history })
+      footer.event({ type: "first", first: resumed.first })
     })
     return state.session
   }
+  const modelTask = firstPaint.then(async () => {
+    if (footer.isClosed) return
+    await ensureSession()
+    if (footer.isClosed) return
+    return loadModel()
+  })
   const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
     state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
   }
@@ -640,6 +667,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
   const runQueue = async () => {
     await firstPaint
     if (footer.isClosed) return
+    await ensureSession()
+    if (footer.isClosed) return
+    await modelTask
+    if (footer.isClosed) return
     let includeFiles = true
     if (state.demo) {
       await state.demo.start()
@@ -728,7 +759,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
                 text: error instanceof Error ? error.message : String(error),
                 phase: "start",
                 source: "system",
-                messageID: MessageID.ascending(),
+                messageID: SessionMessage.ID.create(),
               } as const
               rememberLocal(commit)
               footer.append(commit)
@@ -834,61 +865,62 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
   }
 }
 
-// Local in-process mode. Creates an SDK client backed by a direct fetch to
-// the in-process server, so no external HTTP server is needed.
-export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
-  const sdk = createOpencodeClient({
-    baseUrl: "http://opencode.internal",
-    fetch: input.fetch,
-    directory: input.directory,
-  })
+// Deferred mode paints before session resolution. The caller may back the
+// generated client with a transport that is still acquiring a daemon.
+export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
+  const sdk = input.sdk
   let session: Promise<ResolvedSession> | undefined
 
-  return runInteractiveRuntime({
-    files: input.files,
-    initialInput: input.initialInput,
-    thinking: input.thinking,
-    backgroundSubagents: input.backgroundSubagents,
-    replay: input.replay,
-    replayLimit: input.replayLimit,
-    demo: input.demo,
-    resolveSession: () => {
-      if (session) {
-        return session
-      }
-
-      session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
-        if (!next?.id) {
-          throw new Error("Session not found")
+  return runInteractiveRuntime(
+    {
+      files: input.files,
+      initialInput: input.initialInput,
+      thinking: input.thinking,
+      backgroundSubagents: input.backgroundSubagents,
+      replay: input.replay,
+      replayLimit: input.replayLimit,
+      demo: input.demo,
+      tuiConfig: input.tuiConfig,
+      resolveSession: () => {
+        if (session) {
+          return session
         }
 
+        session = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, next]) => {
+          if (!next?.id) {
+            throw new Error("Session not found")
+          }
+
+          return {
+            sessionID: next.id,
+            sessionTitle: next.title,
+            agent,
+            resume: next.resume,
+          }
+        })
+        return session
+      },
+      createSession: createSessionResolver(input.createSession),
+      boot: async () => {
         return {
-          sessionID: next.id,
-          sessionTitle: next.title,
-          agent,
+          sdk,
+          directory: input.directory,
+          sessionID: "",
+          sessionTitle: undefined,
+          resume: false,
+          agent: input.agent,
+          model: input.model,
+          variant: input.variant,
         }
-      })
-      return session
-    },
-    createSession: createSessionResolver(input.createSession),
-    boot: async () => {
-      return {
-        sdk,
-        directory: input.directory,
-        sessionID: "",
-        sessionTitle: undefined,
-        resume: false,
-        agent: input.agent,
-        model: input.model,
-        variant: input.variant,
-      }
+      },
     },
-  })
+    deps,
+  )
 }
 
 // Attach mode. Uses the caller-provided SDK client directly.
 export async function runInteractiveMode(
-  input: RunInput & { createSession?: CreateSession },
+  input: RunInput & { createSession?: CreateSession; tuiConfig?: RunTuiConfig | Promise<RunTuiConfig> },
   deps?: RunRuntimeDeps,
 ): Promise<void> {
   return runInteractiveRuntime(
@@ -900,6 +932,7 @@ export async function runInteractiveMode(
       replay: input.replay,
       replayLimit: input.replayLimit,
       demo: input.demo,
+      tuiConfig: input.tuiConfig,
       boot: async () => ({
         sdk: input.sdk,
         directory: input.directory,

+ 0 - 0
packages/opencode/src/cli/cmd/run/scrollback.shared.ts → packages/cli/src/mini/scrollback.shared.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/scrollback.surface.ts → packages/cli/src/mini/scrollback.surface.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx → packages/cli/src/mini/scrollback.writer.tsx


+ 1 - 1
packages/opencode/src/cli/cmd/run/session-data.ts → packages/cli/src/mini/session-data.ts

@@ -25,7 +25,7 @@
 //   event arrives, the queue entry is removed and the footer falls back
 //   to the next pending request or to the prompt view.
 import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
-import * as Locale from "@/util/locale"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import { toolView } from "./tool"
 import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
 

+ 12 - 11
packages/opencode/src/cli/cmd/run/session.shared.ts → packages/cli/src/mini/session.shared.ts

@@ -4,11 +4,12 @@
 // the prompt history ring. Also finds the most recently used variant for
 // the current model so the footer can pre-select it.
 import { promptCopy, promptSame } from "./prompt.shared"
+import type { Message, Part } from "@opencode-ai/sdk/v2"
 import type { RunInput, RunPrompt } from "./types"
 
 const LIMIT = 200
 
-export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
+export type SessionMessages = Array<{ info: Message; parts: Part[] }>
 
 type Turn = {
   prompt: RunPrompt
@@ -160,10 +161,10 @@ export async function resolveCurrentSession(
   limit = LIMIT,
 ): Promise<RunSession> {
   const [response, session] = await Promise.all([
-    sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }),
-    sdk.v2.session.get({ sessionID }, { throwOnError: true }),
+    sdk.message.list({ sessionID, limit, order: "desc" }),
+    sdk.session.get({ sessionID }),
   ])
-  const messages = response.data.data.toReversed()
+  const messages = response.data.toReversed()
   return {
     first: messages.length === 0,
     turns: messages.flatMap((message) => {
@@ -195,18 +196,18 @@ export async function resolveCurrentSession(
               })),
             ],
           },
-          provider: session.data.data.model?.providerID,
-          model: session.data.data.model?.id,
-          variant: session.data.data.model?.variant,
+          provider: session.model?.providerID,
+          model: session.model?.id,
+          variant: session.model?.variant,
         },
       ]
     }),
-    ...(session.data.data.model && {
+    ...(session.model && {
       model: {
-        providerID: session.data.data.model.providerID,
-        modelID: session.data.data.model.id,
+        providerID: session.model.providerID,
+        modelID: session.model.id,
       },
-      variant: session.data.data.model.variant,
+      variant: session.model.variant,
     }),
   }
 }

+ 2 - 2
packages/opencode/src/cli/cmd/run/splash.ts → packages/cli/src/mini/splash.ts

@@ -17,8 +17,8 @@ import {
   type ScrollbackSnapshot,
   type ScrollbackWriter,
 } from "@opentui/core"
-import * as Locale from "@/util/locale"
-import { go } from "@/cli/logo"
+import { Locale } from "@opencode-ai/tui/util/locale"
+import { go } from "@opencode-ai/tui/logo"
 import type { RunSplashTheme } from "./theme"
 
 export const SPLASH_TITLE_LIMIT = 50

+ 19 - 23
packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts → packages/cli/src/mini/stream-v2.subagent.ts

@@ -15,14 +15,9 @@
 // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
 // backgrounding is intentionally absent: subagent jobs block the parent
 // session, so only whole-session `v2.session.background(parentID)` exists.
-import type {
-  OpencodeClient,
-  SessionMessage,
-  SessionMessageAssistantTool,
-  ToolPart,
-  V2Event,
-} from "@opencode-ai/sdk/v2"
-import { Locale } from "@/util/locale"
+import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
+import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
 
 const CHILD_MESSAGE_LIMIT = 80
@@ -31,6 +26,8 @@ const CHILD_EVENT_BUFFER_LIMIT = 64
 const FAMILY_LIST_LIMIT = 100
 const FALLBACK_LABEL = "Subagent"
 
+type V2Event = EventSubscribeOutput
+
 export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
   return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
 }
@@ -165,7 +162,7 @@ type ChildState = {
 }
 
 export type SubagentTrackerInput = {
-  sdk: OpencodeClient
+  sdk: OpenCodeClient
   sessionID: string
   thinking: boolean
   emit: () => void
@@ -390,8 +387,8 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
     const pendingPrompts = new Map(child.prompts)
     const pendingTools = new Map(child.tools)
     let retry = false
-    const task = input.sdk.v2.session
-      .messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
+    const task = input.sdk.message
+      .list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
       .then((response) => {
         const buffered = hydrationEvents.get(child.sessionID) ?? []
         hydrationEvents.delete(child.sessionID)
@@ -404,7 +401,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
         for (const [id, prompt] of pendingPrompts) {
           if (!child.prompts.has(id)) child.prompts.set(id, prompt)
         }
-        rebuild(child, response.data.data.toReversed())
+        rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
         for (const [id, tool] of pendingTools) {
           if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
         }
@@ -428,10 +425,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
     if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
     checked.add(sessionID)
     if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
-    void input.sdk.v2.session
-      .get({ sessionID }, { throwOnError: true })
-      .then((response) => {
-        const session = response.data.data
+    void input.sdk.session
+      .get({ sessionID })
+      .then((session) => {
         const buffered = pendingEvents.get(sessionID) ?? []
         pendingEvents.delete(sessionID)
         if (session.parentID !== input.sessionID) return
@@ -558,14 +554,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       })
       childTool(
         child,
-        {
+        structuredClone({
           type: "tool",
           id: event.data.callID,
           name: event.data.tool,
           provider: event.data.provider,
           state: { status: "running", input: event.data.input, structured: {}, content: [] },
           time: { created: current?.started ?? event.created, ran: event.created },
-        },
+        }) as SessionMessageAssistantTool,
         event.data.assistantMessageID,
       )
       touch(child, event.created)
@@ -578,7 +574,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       const failed = event.type === "session.tool.failed"
       childTool(
         child,
-        {
+        structuredClone({
           type: "tool",
           id: event.data.callID,
           name: current?.name ?? "tool",
@@ -605,7 +601,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
             ran: current?.started,
             completed: event.created,
           },
-        },
+        }) as SessionMessageAssistantTool,
         event.data.assistantMessageID,
       )
       touch(child, event.created)
@@ -697,9 +693,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
       }
       // Family index: adopt children directly from the current session list so
       // historical subagents beyond the projected message window still get tabs.
-      const family = await input.sdk.v2.session
-        .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
-        .then((response) => response.data.data)
+      const family = await input.sdk.session
+        .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
+        .then((response) => response.data)
         .catch(() => [])
       for (const session of family) {
         const child = ensureChild(session.id)

+ 45 - 51
packages/opencode/src/cli/cmd/run/stream-v2.transport.ts → packages/cli/src/mini/stream-v2.transport.ts

@@ -1,13 +1,13 @@
 import type {
-  OpencodeClient,
+  EventSubscribeOutput,
+  OpenCodeClient,
+} from "@opencode-ai/client/promise"
+import type {
   PermissionRequest,
-  PermissionV2Request,
   QuestionRequest,
-  QuestionV2Request,
   SessionMessage,
   SessionMessageAssistant,
   SessionMessageAssistantTool,
-  V2Event,
 } from "@opencode-ai/sdk/v2"
 import { Event } from "@opencode-ai/schema/event"
 import { blockerStatus, pickBlockerView } from "./session-data"
@@ -31,7 +31,7 @@ type Trace = {
 }
 
 type StreamInput = {
-  sdk: OpencodeClient
+  sdk: OpenCodeClient
   directory?: string
   sessionID: string
   thinking: boolean
@@ -90,7 +90,9 @@ type ShellWait = {
   abort: () => void
 }
 
-type RunV2Event = V2Event
+type RunV2Event = EventSubscribeOutput
+type PermissionV2Request = Extract<RunV2Event, { type: "permission.v2.asked" }>["data"]
+type QuestionV2Request = Extract<RunV2Event, { type: "question.v2.asked" }>["data"]
 type PromptFilePart = Extract<RunPromptPart, { type: "file" }>
 
 type ToolState = {
@@ -144,9 +146,9 @@ function permission(request: PermissionV2Request): PermissionRequest {
     id: request.id,
     sessionID: request.sessionID,
     permission: request.action,
-    patterns: request.resources,
+    patterns: [...request.resources],
     metadata: request.metadata ?? {},
-    always: request.save ?? [],
+    always: [...(request.save ?? [])],
     tool: request.source?.type === "tool" ? request.source : undefined,
   }
 }
@@ -155,7 +157,7 @@ function question(request: QuestionV2Request): QuestionRequest {
   return {
     id: request.id,
     sessionID: request.sessionID,
-    questions: request.questions,
+    questions: request.questions.map((item) => ({ ...item, options: item.options.map((option) => ({ ...option })) })),
     tool: request.tool,
   }
 }
@@ -303,14 +305,14 @@ async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnIn
   if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
   if (!next.variant) return
 
-  const session = await input.sdk.v2.session
-    .get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal })
-    .then((response) => response.data.data.model)
+  const session = await input.sdk.session
+    .get({ sessionID: input.sessionID }, { signal: next.signal })
+    .then((response) => response.model)
   if (session) return { ...session, variant: next.variant }
 
-  const fallback = await input.sdk.v2.model
-    .default(undefined, { throwOnError: true, signal: next.signal })
-    .then((response) => response.data.data)
+  const fallback = await input.sdk.model
+    .default(undefined, { signal: next.signal })
+    .then((response) => response.data)
   if (!fallback) return
   return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
 }
@@ -532,21 +534,18 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
 
   const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => {
     const [messages, permissions, questions, active] = await Promise.all([
-      input.sdk.v2.session.messages(
-        { sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" },
-        { throwOnError: true },
-      ),
-      input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
-      input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
-      input.sdk.v2.session.active({ throwOnError: true }),
+      input.sdk.message.list({ sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }),
+      input.sdk.permission.list({ sessionID: input.sessionID }),
+      input.sdk.question.list({ sessionID: input.sessionID }),
+      input.sdk.session.active(),
     ])
-    const projected = messages.data.data.toReversed()
+    const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
     for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
-    state.permissions = permissions.data.data.map(permission)
-    state.questions = questions.data.data.map(question)
+    state.permissions = permissions.map(permission)
+    state.questions = questions.map(question)
     syncBlockers()
-    await subagents.hydrate({ messages: projected, active: active.data.data })
-    const running = input.sessionID in active.data.data
+    await subagents.hydrate({ messages: [...projected], active: active.data })
+    const running = input.sessionID in active.data
     write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" })
     if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) {
       const current = state.wait
@@ -721,14 +720,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     if (event.type === "session.tool.called") {
       if (state.finishedTools.has(event.data.callID)) return
       const current = state.tools.get(event.data.callID)
-      const item: SessionMessageAssistantTool = {
+      const item = structuredClone({
         type: "tool",
         id: event.data.callID,
         name: event.data.tool,
         provider: event.data.provider,
         state: { status: "running", input: event.data.input, structured: {}, content: [] },
         time: { created: current?.started ?? event.created, ran: event.created },
-      }
+      }) as SessionMessageAssistantTool
       renderTool(event.data.assistantMessageID, item)
       return
     }
@@ -736,7 +735,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
       const current = state.tools.get(event.data.callID)
       const failed = event.type === "session.tool.failed"
-      const item: SessionMessageAssistantTool = {
+      const item = structuredClone({
         type: "tool",
         id: event.data.callID,
         name: current?.name ?? "tool",
@@ -759,7 +758,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
               result: event.data.result,
             },
         time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
-      }
+      }) as SessionMessageAssistantTool
       renderTool(event.data.assistantMessageID, item)
       return
     }
@@ -838,12 +837,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         const connection = new AbortController()
         const abortConnection = () => connection.abort()
         controller.signal.addEventListener("abort", abortConnection, { once: true })
-        const response = await input.sdk.v2.event.subscribe({
-          signal: connection.signal,
-          sseMaxRetryAttempts: 0,
-          throwOnError: true,
-        })
-        const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
+        const stream = input.sdk.event.subscribe({ signal: connection.signal })[Symbol.asyncIterator]()
         try {
           const first = await stream.next()
           if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected")
@@ -910,9 +904,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     input.trace?.write("send.shell", { sessionID: input.sessionID, id: eventID, command: next.prompt.text })
     write([], { phase: "running", status: "running shell" })
     try {
-      await input.sdk.v2.session.shell(
+      await input.sdk.session.shell(
         { sessionID: input.sessionID, id: eventID, command: next.prompt.text },
-        { throwOnError: true, signal: abort.signal },
+        { signal: abort.signal },
       )
       await Promise.race([output, wait(SHELL_OUTPUT_GRACE_MS, abort.signal)])
     } catch (error) {
@@ -950,7 +944,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
     state.wait = active
     const interrupt = () => {
       active.interrupted = true
-      void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+      void input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
     }
     next.signal?.addEventListener("abort", interrupt, { once: true })
     try {
@@ -981,9 +975,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
         await runTurnWait(next, messageID, {
           send: () =>
-            input.sdk.v2.session.skill(
+            input.sdk.session.skill(
               { sessionID: input.sessionID, id: messageID, skill: command.name },
-              { throwOnError: true, signal: next.signal },
+              { signal: next.signal },
             ),
         })
         return
@@ -1001,7 +995,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name })
         await runTurnWait(next, messageID, {
           send: () =>
-            input.sdk.v2.session.command(
+            input.sdk.session.command(
               {
                 sessionID: input.sessionID,
                 id: messageID,
@@ -1013,24 +1007,24 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
                 agents: agents.length ? agents : undefined,
                 delivery: "steer",
               },
-              { throwOnError: true, signal: next.signal },
+              { signal: next.signal },
             ),
         })
         return
       }
 
       if (next.agent) {
-        await input.sdk.v2.session.switchAgent(
+        await input.sdk.session.switchAgent(
           { sessionID: input.sessionID, agent: next.agent },
-          { throwOnError: true, signal: next.signal },
+          { signal: next.signal },
         )
       }
       const selected = await resolveSelectedModel(input, next)
       if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
       if (selected)
-        await input.sdk.v2.session.switchModel(
+        await input.sdk.session.switchModel(
           { sessionID: input.sessionID, model: selected },
-          { throwOnError: true, signal: next.signal },
+          { signal: next.signal },
         )
 
       const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
@@ -1042,7 +1036,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID })
       await runTurnWait(next, messageID, {
         send: () =>
-          input.sdk.v2.session.prompt(
+          input.sdk.session.prompt(
             {
               sessionID: input.sessionID,
               id: messageID,
@@ -1053,7 +1047,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
               },
               delivery: "steer",
             },
-            { throwOnError: true, signal: next.signal },
+            { signal: next.signal },
           ),
       })
     },
@@ -1067,7 +1061,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         return
       }
       if (state.wait) state.wait.interrupted = true
-      await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
+      await input.sdk.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
     },
     selectSubagent(sessionID) {
       subagents.select(sessionID)

+ 0 - 0
packages/opencode/src/cli/cmd/run/stream.ts → packages/cli/src/mini/stream.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/theme.ts → packages/cli/src/mini/theme.ts


+ 138 - 116
packages/opencode/src/cli/cmd/run/tool.ts → packages/cli/src/mini/tool.ts

@@ -16,25 +16,8 @@ import os from "os"
 import path from "path"
 import stripAnsi from "strip-ansi"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
-import type * as Tool from "@/tool/tool"
-import type { ApplyPatchTool } from "@/tool/apply_patch"
-import type { ShellTool as BashTool } from "@/tool/shell"
-import type { EditTool } from "@/tool/edit"
-import type { GlobTool } from "@/tool/glob"
-import type { GrepTool } from "@/tool/grep"
-import type { InvalidTool } from "@/tool/invalid"
-import type { LspTool } from "@/tool/lsp"
-import type { PlanExitTool } from "@/tool/plan"
-import type { QuestionTool } from "@/tool/question"
-import type { ReadTool } from "@/tool/read"
-import type { SkillTool } from "@/tool/skill"
-import type { TaskTool } from "@/tool/task"
-import type { TodoWriteTool } from "@/tool/todo"
-import type { WebFetchTool } from "@/tool/webfetch"
-import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch"
-import type { WriteTool } from "@/tool/write"
-import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
-import * as Locale from "@/util/locale"
+import { LANGUAGE_EXTENSIONS } from "@opencode-ai/tui/util/filetype"
+import { Locale } from "@opencode-ai/tui/util/locale"
 import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
 
 export type ToolView = {
@@ -47,6 +30,46 @@ export type ToolPhase = "start" | "progress" | "final"
 
 export type ToolDict = Record<string, unknown>
 
+type PatchFile = {
+  type?: string
+  relativePath?: string
+  filePath?: string
+  movePath?: string
+  patch?: string
+  deletions?: number
+}
+
+type ToolInput = ToolDict & {
+  path?: string
+  pattern?: string
+  filePath?: string
+  filepath?: string
+  url?: string
+  query?: string
+  subagent_type?: string
+  description?: string
+  name?: string
+  operation?: string
+  line?: number
+  character?: number
+  content?: string
+  command?: string
+  workdir?: string
+  todos?: Array<{ status?: string; content?: string }>
+  questions?: Array<{ question?: string }>
+  diff?: string
+}
+
+type ToolMetadata = ToolDict & {
+  count?: number
+  matches?: number
+  diff?: string
+  provider?: unknown
+  files?: PatchFile[]
+  answers?: string[][]
+  exit?: number
+}
+
 export type ToolFrame = {
   raw: string
   name: string
@@ -73,15 +96,15 @@ export type ToolPermissionInfo = {
   file?: string
 }
 
-export type ToolProps<T = Tool.Info> = {
-  input: Partial<Tool.InferParameters<T>>
-  metadata: Partial<Tool.InferMetadata<T>>
+export type ToolProps = {
+  input: ToolInput
+  metadata: ToolMetadata
   frame: ToolFrame
 }
 
-type ToolPermissionProps<T = Tool.Info> = {
-  input: Partial<Tool.InferParameters<T>>
-  metadata: Partial<Tool.InferMetadata<T>>
+type ToolPermissionProps = {
+  input: ToolInput
+  metadata: ToolMetadata
   patterns: string[]
 }
 
@@ -91,40 +114,35 @@ type ToolPermissionCtx = {
   patterns: string[]
 }
 
-type ToolDefs = {
-  invalid: typeof InvalidTool
-  bash: typeof BashTool
-  write: typeof WriteTool
-  edit: typeof EditTool
-  apply_patch: typeof ApplyPatchTool
-  batch: Tool.Info
-  task: typeof TaskTool
-  todowrite: typeof TodoWriteTool
-  question: typeof QuestionTool
-  read: typeof ReadTool
-  glob: typeof GlobTool
-  grep: typeof GrepTool
-  list: Tool.Info
-  lsp: typeof LspTool
-  webfetch: typeof WebFetchTool
-  websearch: typeof WebSearchTool
-  skill: typeof SkillTool
-  plan_exit: typeof PlanExitTool
-}
-
-type ToolName = keyof ToolDefs
-
-type ToolRule<T = Tool.Info> = {
+type ToolName =
+  | "invalid"
+  | "bash"
+  | "write"
+  | "edit"
+  | "apply_patch"
+  | "batch"
+  | "task"
+  | "todowrite"
+  | "question"
+  | "read"
+  | "glob"
+  | "grep"
+  | "list"
+  | "lsp"
+  | "webfetch"
+  | "websearch"
+  | "skill"
+  | "plan_exit"
+
+type ToolRule = {
   view: ToolView
-  run: (props: ToolProps<T>) => ToolInline
-  scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
-  permission?: (props: ToolPermissionProps<T>) => ToolPermissionInfo
-  snap?: (props: ToolProps<T>) => ToolSnapshot | undefined
+  run: (props: ToolProps) => ToolInline
+  scroll?: Partial<Record<ToolPhase, (props: ToolProps) => string>>
+  permission?: (props: ToolPermissionProps) => ToolPermissionInfo
+  snap?: (props: ToolProps) => ToolSnapshot | undefined
 }
 
-type ToolRegistry = {
-  [K in ToolName]: ToolRule<ToolDefs[K]>
-}
+type ToolRegistry = Record<ToolName, ToolRule>
 
 type AnyToolRule = ToolRule
 
@@ -136,22 +154,28 @@ function dict(v: unknown): ToolDict {
   return { ...v }
 }
 
-function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
+function props(frame: ToolFrame): ToolProps {
   return {
-    input: Object.assign(Object.create(null), frame.input),
-    metadata: Object.assign(Object.create(null), frame.meta),
+    input: frame.input,
+    metadata: frame.meta,
     frame,
   }
 }
 
-function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
+function permission(ctx: ToolPermissionCtx): ToolPermissionProps {
   return {
-    input: Object.assign(Object.create(null), ctx.input),
-    metadata: Object.assign(Object.create(null), ctx.meta),
+    input: ctx.input,
+    metadata: ctx.meta,
     patterns: ctx.patterns,
   }
 }
 
+function webSearchProviderLabel(provider: unknown) {
+  if (provider === "parallel") return "Parallel Web Search"
+  if (provider === "exa") return "Exa Web Search"
+  return "Web Search"
+}
+
 function text(v: unknown): string {
   return typeof v === "string" ? v : ""
 }
@@ -285,7 +309,7 @@ function count(n: number, label: string): string {
   return `${n} ${label}${n === 1 ? "" : "es"}`
 }
 
-function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
+function runGlob(p: ToolProps): ToolInline {
   const root = p.input.path ?? ""
   const title = `Glob "${p.input.pattern ?? ""}"`
   const suffix = root ? `in ${toolPath(root)}` : ""
@@ -298,7 +322,7 @@ function runGlob(p: ToolProps<typeof GlobTool>): ToolInline {
   }
 }
 
-function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
+function runGrep(p: ToolProps): ToolInline {
   const root = p.input.path ?? ""
   const title = `Grep "${p.input.pattern ?? ""}"`
   const suffix = root ? `in ${toolPath(root)}` : ""
@@ -319,7 +343,7 @@ function runList(p: ToolProps): ToolInline {
   }
 }
 
-function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
+function runRead(p: ToolProps): ToolInline {
   const file = toolPath(p.input.filePath)
   const description = info(p.frame.input, ["filePath"]) || undefined
   return {
@@ -329,7 +353,7 @@ function runRead(p: ToolProps<typeof ReadTool>): ToolInline {
   }
 }
 
-function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
+function runWrite(p: ToolProps): ToolInline {
   return {
     icon: "←",
     title: `Write ${toolPath(p.input.filePath)}`,
@@ -338,7 +362,7 @@ function runWrite(p: ToolProps<typeof WriteTool>): ToolInline {
   }
 }
 
-function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
+function runWebfetch(p: ToolProps): ToolInline {
   const url = p.input.url ?? ""
   return {
     icon: "%",
@@ -346,7 +370,7 @@ function runWebfetch(p: ToolProps<typeof WebFetchTool>): ToolInline {
   }
 }
 
-function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
+function runEdit(p: ToolProps): ToolInline {
   return {
     icon: "←",
     title: `Edit ${toolPath(p.input.filePath)}`,
@@ -355,7 +379,7 @@ function runEdit(p: ToolProps<typeof EditTool>): ToolInline {
   }
 }
 
-function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
+function runWebSearch(p: ToolProps): ToolInline {
   const title = webSearchProviderLabel(p.metadata.provider)
   return {
     icon: "◈",
@@ -363,7 +387,7 @@ function runWebSearch(p: ToolProps<typeof WebSearchTool>): ToolInline {
   }
 }
 
-function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
+function runTask(p: ToolProps): ToolInline {
   const kind = Locale.titlecase(p.input.subagent_type || "unknown")
   const desc = p.input.description
   const icon = p.frame.status === "error" ? "✗" : p.frame.status === "running" ? "•" : "✓"
@@ -374,7 +398,7 @@ function runTask(p: ToolProps<typeof TaskTool>): ToolInline {
   }
 }
 
-function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
+function runTodo(p: ToolProps): ToolInline {
   return {
     icon: "#",
     title: "Todos",
@@ -393,14 +417,14 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
   }
 }
 
-function runSkill(p: ToolProps<typeof SkillTool>): ToolInline {
+function runSkill(p: ToolProps): ToolInline {
   return {
     icon: "→",
     title: `Skill "${p.input.name ?? ""}"`,
   }
 }
 
-function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
+function runPatch(p: ToolProps): ToolInline {
   const files = p.metadata.files?.length ?? 0
   if (files === 0) {
     return {
@@ -415,7 +439,7 @@ function runPatch(p: ToolProps<typeof ApplyPatchTool>): ToolInline {
   }
 }
 
-function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
+function runQuestion(p: ToolProps): ToolInline {
   const total = list(p.frame.input.questions).length
   return {
     icon: "→",
@@ -423,7 +447,7 @@ function runQuestion(p: ToolProps<typeof QuestionTool>): ToolInline {
   }
 }
 
-function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
+function runInvalid(p: ToolProps): ToolInline {
   return {
     icon: "✗",
     title: text(p.frame.state.title) || "Invalid Tool",
@@ -463,14 +487,14 @@ function lspTitle(
   return `LSP ${op} ${file}${pos}`
 }
 
-function runLsp(p: ToolProps<typeof LspTool>): ToolInline {
+function runLsp(p: ToolProps): ToolInline {
   return {
     icon: "→",
     title: text(p.frame.state.title) || lspTitle(p.input),
   }
 }
 
-function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
+function runPlanExit(p: ToolProps): ToolInline {
   return {
     icon: "→",
     title: text(p.frame.state.title) || "Switching to build agent",
@@ -479,8 +503,6 @@ function runPlanExit(p: ToolProps<typeof PlanExitTool>): ToolInline {
   }
 }
 
-type PatchFile = Tool.InferMetadata<typeof ApplyPatchTool>["files"][number]
-
 function patchTitle(file: PatchFile): string {
   const rel = file.relativePath
   const from = file.filePath
@@ -497,7 +519,7 @@ function patchTitle(file: PatchFile): string {
   return `# Patched ${rel || toolPath(from)}`
 }
 
-function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
+function snapWrite(p: ToolProps): ToolSnapshot | undefined {
   const file = p.input.filePath || ""
   const content = p.input.content || ""
   if (!file && !content) {
@@ -512,7 +534,7 @@ function snapWrite(p: ToolProps<typeof WriteTool>): ToolSnapshot | undefined {
   }
 }
 
-function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
+function snapEdit(p: ToolProps): ToolSnapshot | undefined {
   const file = p.input.filePath || ""
   const diff = p.metadata.diff || ""
   if (!file || !diff.trim()) {
@@ -531,7 +553,7 @@ function snapEdit(p: ToolProps<typeof EditTool>): ToolSnapshot | undefined {
   }
 }
 
-function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefined {
+function snapPatch(p: ToolProps): ToolSnapshot | undefined {
   const files = list<PatchFile>(p.frame.meta.files)
   if (files.length === 0) {
     return undefined
@@ -568,7 +590,7 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
   }
 }
 
-function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
+function snapTask(p: ToolProps): ToolSnapshot {
   const kind = Locale.titlecase(p.input.subagent_type || "general")
   const desc = p.input.description
   const title = text(p.frame.state.title)
@@ -582,7 +604,7 @@ function snapTask(p: ToolProps<typeof TaskTool>): ToolSnapshot {
   }
 }
 
-function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
+function snapTodo(p: ToolProps): ToolSnapshot {
   const items = list<{ status?: string; content?: string }>(p.frame.input.todos).flatMap((item) => {
     const content = typeof item?.content === "string" ? item.content : ""
     if (!content) {
@@ -604,7 +626,7 @@ function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
   }
 }
 
-function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
+function snapQuestion(p: ToolProps): ToolSnapshot {
   const answers = list<unknown[]>(p.frame.meta.answers)
   const items = list<{ question?: string }>(p.frame.input.questions).map((item, i) => {
     const answer = list<string>(answers[i]).filter((entry) => typeof entry === "string")
@@ -621,7 +643,7 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
   }
 }
 
-function scrollBashStart(p: ToolProps<typeof BashTool>): string {
+function scrollBashStart(p: ToolProps): string {
   const cmd = p.input.command ?? ""
   const wd = p.input.workdir ?? ""
   const formatted = wd && wd !== "." ? toolPath(wd) : ""
@@ -637,7 +659,7 @@ function scrollBashStart(p: ToolProps<typeof BashTool>): string {
   return `# Running in ${dir}\n$ ${cmd}`
 }
 
-function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
+function scrollBashProgress(p: ToolProps): string {
   const out = stripAnsi(p.frame.raw)
   const cmd = (p.input.command ?? "").trim()
   const fmt = (text: string) => {
@@ -670,7 +692,7 @@ function scrollBashProgress(p: ToolProps<typeof BashTool>): string {
   return fmt(out)
 }
 
-function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
+function scrollBashFinal(p: ToolProps): string {
   if (p.frame.status === "error") {
     return fail(p.frame)
   }
@@ -688,22 +710,22 @@ function scrollBashFinal(p: ToolProps<typeof BashTool>): string {
   return `bash completed (exit ${code})${time ? ` · ${time}` : ""}`
 }
 
-function scrollReadStart(p: ToolProps<typeof ReadTool>): string {
+function scrollReadStart(p: ToolProps): string {
   const file = toolPath(p.input.filePath)
   const extra = info(p.frame.input, ["filePath"])
   const tail = extra ? ` ${extra}` : ""
   return `→ Read ${file}${tail}`.trim()
 }
 
-function scrollWriteStart(_: ToolProps<typeof WriteTool>): string {
+function scrollWriteStart(_: ToolProps): string {
   return ""
 }
 
-function scrollEditStart(_: ToolProps<typeof EditTool>): string {
+function scrollEditStart(_: ToolProps): string {
   return ""
 }
 
-function scrollPatchStart(_: ToolProps<typeof ApplyPatchTool>): string {
+function scrollPatchStart(_: ToolProps): string {
   return ""
 }
 
@@ -727,7 +749,7 @@ function patchLine(file: PatchFile): string {
   return `~ Patched ${rel || toolPath(from)}`
 }
 
-function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
+function scrollPatchFinal(p: ToolProps): string {
   if (p.frame.status === "error") {
     return fail(p.frame)
   }
@@ -756,7 +778,7 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
   return patchLine(files[0]!)
 }
 
-function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
+function scrollTaskStart(_: ToolProps): string {
   return ""
 }
 
@@ -778,7 +800,7 @@ function taskResult(output: string): string | undefined {
   return next || undefined
 }
 
-function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
+function scrollTaskFinal(p: ToolProps): string {
   if (p.frame.status === "error") {
     return fail(p.frame)
   }
@@ -792,11 +814,11 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
   return `# ${kind} Task\n${row}`
 }
 
-function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
+function scrollTodoStart(_: ToolProps): string {
   return ""
 }
 
-function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
+function scrollTodoFinal(p: ToolProps): string {
   const items = list<{ status?: string }>(p.input.todos)
   const time = span(p.frame.state)
   if (items.length === 0) {
@@ -828,11 +850,11 @@ function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
   return tail.join(" · ")
 }
 
-function scrollQuestionStart(_: ToolProps<typeof QuestionTool>): string {
+function scrollQuestionStart(_: ToolProps): string {
   return ""
 }
 
-function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
+function scrollQuestionFinal(p: ToolProps): string {
   const q = p.input.questions ?? []
   const a = p.metadata.answers ?? []
   const time = span(p.frame.state)
@@ -859,15 +881,15 @@ function scrollQuestionFinal(p: ToolProps<typeof QuestionTool>): string {
   return rows.join("\n")
 }
 
-function scrollLspStart(p: ToolProps<typeof LspTool>): string {
+function scrollLspStart(p: ToolProps): string {
   return `→ ${lspTitle(p.input)}`
 }
 
-function scrollSkillStart(p: ToolProps<typeof SkillTool>): string {
+function scrollSkillStart(p: ToolProps): string {
   return `→ Skill "${p.input.name ?? ""}"`
 }
 
-function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
+function scrollGlobStart(p: ToolProps): string {
   const pattern = p.input.pattern ?? ""
   const head = pattern ? `✱ Glob "${pattern}"` : "✱ Glob"
   const dir = p.input.path ?? ""
@@ -878,11 +900,11 @@ function scrollGlobStart(p: ToolProps<typeof GlobTool>): string {
   return `${head} in ${toolPath(dir)}`
 }
 
-function scrollGlobFinal(p: ToolProps<typeof GlobTool>): string {
+function scrollGlobFinal(p: ToolProps): string {
   return toolError(p.frame) || fail(p.frame)
 }
 
-function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
+function scrollGrepStart(p: ToolProps): string {
   const pattern = p.input.pattern ?? ""
   const head = pattern ? `✱ Grep "${pattern}"` : "✱ Grep"
   const dir = p.input.path ?? ""
@@ -902,7 +924,7 @@ function scrollListStart(p: ToolProps): string {
   return `→ List ${toolPath(dir)}`
 }
 
-function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
+function scrollWebfetchStart(p: ToolProps): string {
   const url = p.input.url ?? ""
   if (!url) {
     return "% WebFetch"
@@ -911,7 +933,7 @@ function scrollWebfetchStart(p: ToolProps<typeof WebFetchTool>): string {
   return `% WebFetch ${url}`
 }
 
-function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
+function scrollWebSearchStart(p: ToolProps): string {
   const title = webSearchProviderLabel(p.metadata.provider)
   const query = p.input.query ?? ""
   if (!query) {
@@ -921,7 +943,7 @@ function scrollWebSearchStart(p: ToolProps<typeof WebSearchTool>): string {
   return `◈ ${title} "${query}"`
 }
 
-function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
+function permEdit(p: ToolPermissionProps): ToolPermissionInfo {
   const input = p.input as { filePath?: string; filepath?: string; diff?: string }
   const file = input.filePath || input.filepath || p.patterns[0] || ""
   return {
@@ -933,7 +955,7 @@ function permEdit(p: ToolPermissionProps<typeof EditTool>): ToolPermissionInfo {
   }
 }
 
-function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
+function permRead(p: ToolPermissionProps): ToolPermissionInfo {
   const file = p.input.filePath || p.patterns[0] || ""
   return {
     icon: "→",
@@ -942,7 +964,7 @@ function permRead(p: ToolPermissionProps<typeof ReadTool>): ToolPermissionInfo {
   }
 }
 
-function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
+function permGlob(p: ToolPermissionProps): ToolPermissionInfo {
   const pattern = p.input.pattern || p.patterns[0] || ""
   return {
     icon: "✱",
@@ -951,7 +973,7 @@ function permGlob(p: ToolPermissionProps<typeof GlobTool>): ToolPermissionInfo {
   }
 }
 
-function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
+function permGrep(p: ToolPermissionProps): ToolPermissionInfo {
   const pattern = p.input.pattern || p.patterns[0] || ""
   return {
     icon: "✱",
@@ -969,7 +991,7 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
   }
 }
 
-function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
+function permBash(p: ToolPermissionProps): ToolPermissionInfo {
   const cmd = p.input.command || ""
   return {
     icon: "#",
@@ -978,7 +1000,7 @@ function permBash(p: ToolPermissionProps<typeof BashTool>): ToolPermissionInfo {
   }
 }
 
-function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
+function permTask(p: ToolPermissionProps): ToolPermissionInfo {
   const type = p.input.subagent_type || "general"
   const desc = p.input.description
   return {
@@ -988,7 +1010,7 @@ function permTask(p: ToolPermissionProps<typeof TaskTool>): ToolPermissionInfo {
   }
 }
 
-function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissionInfo {
+function permWebfetch(p: ToolPermissionProps): ToolPermissionInfo {
   const url = p.input.url || ""
   return {
     icon: "%",
@@ -997,7 +1019,7 @@ function permWebfetch(p: ToolPermissionProps<typeof WebFetchTool>): ToolPermissi
   }
 }
 
-function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermissionInfo {
+function permWebSearch(p: ToolPermissionProps): ToolPermissionInfo {
   const query = p.input.query || ""
   const title = webSearchProviderLabel(p.metadata.provider)
   return {
@@ -1007,7 +1029,7 @@ function permWebSearch(p: ToolPermissionProps<typeof WebSearchTool>): ToolPermis
   }
 }
 
-function permLsp(p: ToolPermissionProps<typeof LspTool>): ToolPermissionInfo {
+function permLsp(p: ToolPermissionProps): ToolPermissionInfo {
   const file = p.input.filePath || ""
   const line = typeof p.input.line === "number" ? p.input.line : undefined
   const char = typeof p.input.character === "number" ? p.input.character : undefined
@@ -1273,7 +1295,7 @@ export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
   }
 }
 
-function runBash(p: ToolProps<typeof BashTool>): ToolInline {
+function runBash(p: ToolProps): ToolInline {
   return {
     icon: "$",
     title: p.input.command || "",

+ 0 - 0
packages/opencode/src/cli/cmd/run/trace.ts → packages/cli/src/mini/trace.ts


+ 0 - 0
packages/opencode/src/cli/cmd/run/turn-summary.ts → packages/cli/src/mini/turn-summary.ts


+ 24 - 11
packages/opencode/src/cli/cmd/run/types.ts → packages/cli/src/mini/types.ts

@@ -11,7 +11,8 @@
 //     → stream.ts bridges to footer API
 //       → footer.ts queues commits and patches the footer view
 //         → OpenTUI split-footer renderer writes to terminal
-import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
+import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
+import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
 import type { TuiConfig } from "@opencode-ai/tui/config"
 
 export type RunFilePart = {
@@ -21,10 +22,17 @@ export type RunFilePart = {
   mime: string
 }
 
-type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
-type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
+type PromptModel = { providerID: string; modelID: string }
 
-export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
+export type RunPromptPart =
+  | {
+      type: "file"
+      url: string
+      filename?: string
+      mime?: string
+      source?: FilePart["source"]
+    }
+  | { type: "agent"; name: string; source?: { start: number; end: number; value: string } }
 
 export type RunCommand = {
   name: string
@@ -111,12 +119,10 @@ export type RunAgent = {
   hidden: boolean
 }
 
-export type RunReference = NonNullable<
-  Awaited<ReturnType<OpencodeClient["v2"]["reference"]["list"]>>["data"]
->["data"][number]
+export type RunReference = ReferenceListOutput["data"][number]
 
 export type RunInput = {
-  sdk: OpencodeClient
+  sdk: OpenCodeClient
   directory: string
   sessionID: string
   sessionTitle?: string
@@ -282,6 +288,10 @@ export type FooterOutput = {
 // transport both emit these to update footer state without reaching into
 // internal signals directly.
 export type FooterEvent =
+  | {
+      type: "history"
+      history: RunPrompt[]
+    }
   | {
       type: "catalog"
       agents: RunAgent[]
@@ -342,11 +352,14 @@ export type FooterEvent =
       state: FooterSubagentState
     }
 
-export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
+export type PermissionReply = Omit<Parameters<OpenCodeClient["permission"]["reply"]>[0], "sessionID">
 
-export type QuestionReply = Parameters<OpencodeClient["question"]["reply"]>[0]
+export type QuestionReply = {
+  requestID: string
+  answers: string[][]
+}
 
-export type QuestionReject = Parameters<OpencodeClient["question"]["reject"]>[0]
+export type QuestionReject = Omit<Parameters<OpenCodeClient["question"]["reject"]>[0], "sessionID">
 
 export type RunTuiConfig = Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout" | "diff_style">
 

+ 27 - 0
packages/cli/src/mini/ui.ts

@@ -0,0 +1,27 @@
+import { EOL } from "node:os"
+
+export const Style = {
+  TEXT_DIM: "\x1b[90m",
+  TEXT_NORMAL: "\x1b[0m",
+  TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
+  TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
+}
+
+export function println(...message: string[]) {
+  process.stderr.write(message.join(" ") + EOL)
+}
+
+let blank = false
+
+export function empty() {
+  if (blank) return
+  println(Style.TEXT_NORMAL)
+  blank = true
+}
+
+export function error(message: string) {
+  if (message.startsWith("Error: ")) message = message.slice("Error: ".length)
+  println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
+}
+
+export * as UI from "./ui"

+ 5 - 2
packages/opencode/src/cli/cmd/run/variant.shared.ts → packages/cli/src/mini/variant.shared.ts

@@ -12,9 +12,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Context, Effect, Layer } from "effect"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
-import { makeRuntime } from "@/effect/run-service"
+import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { Global } from "@opencode-ai/core/global"
-import { isRecord } from "@/util/record"
 import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
 import type { RunInput, RunProvider } from "./types"
 
@@ -32,6 +31,10 @@ type VariantRuntime = {
   saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
 }
 
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
 class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
 
 function modelKey(provider: string, model: string): string {

+ 134 - 0
packages/cli/src/server-process.ts

@@ -0,0 +1,134 @@
+export * as ServerProcess from "./server-process"
+
+import { NodeServices } from "@effect/platform-node"
+import { Service } from "@opencode-ai/client/effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { Global } from "@opencode-ai/core/global"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import { AppProcess } from "@opencode-ai/core/process"
+import { Flock } from "@opencode-ai/core/util/flock"
+import { start } from "@opencode-ai/server/process"
+import { randomBytes, randomUUID } from "node:crypto"
+import path from "node:path"
+import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
+import { HttpServer } from "effect/unstable/http"
+import { Env } from "./env"
+import { ServiceConfig } from "./services/service-config"
+import { Updater } from "./services/updater"
+
+export type Mode = "default" | "service" | "stdio"
+
+export type Options = {
+  readonly mode: Mode
+  readonly hostname?: string
+  readonly port?: number
+}
+
+export const run = Effect.fn("cli.server-process.run")((options: Options) =>
+  processEffect(options).pipe(
+    Effect.provide(Updater.layer),
+    Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
+    Effect.provide(NodeServices.layer),
+  ),
+)
+
+const processEffect = Effect.fnUntraced(function* (options: Options) {
+  if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
+  return yield* Effect.scoped(
+    Effect.gen(function* () {
+      if (options.mode === "service") {
+        const service = yield* ServiceConfig.options()
+        yield* Flock.effect("service-process", {
+          dir: path.dirname(service.file),
+          staleMs: 3_000,
+          timeoutMs: 15_000,
+        })
+      }
+      const environmentPassword = yield* Env.password
+      // Keep the lease credential out of the environment inherited by tools.
+      if (options.mode === "stdio") {
+        delete process.env.OPENCODE_PASSWORD
+        delete process.env.OPENCODE_SERVER_PASSWORD
+      }
+      const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
+      const password =
+        options.mode === "service"
+          ? yield* ServiceConfig.password()
+          : environmentPassword
+            ? Redacted.value(environmentPassword)
+            : randomBytes(32).toString("base64url")
+      if (!password) return yield* Effect.fail(new Error("Missing server password"))
+      const address = yield* start({
+        hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
+        port: Option.fromNullishOr(options.port ?? config.port),
+        password,
+      }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
+      if (options.mode === "service") yield* register(address, password)
+      const url = HttpServer.formatAddress(address)
+      console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
+      if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
+      const updater = yield* Updater.Service
+      yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
+      return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
+    }).pipe(Effect.annotateLogs({ role: "server" })),
+  )
+})
+
+// The latest atomic registration wins. A displaced process notices the new id,
+// exits, and cannot remove its successor's registration from its finalizer.
+const infoJson = Schema.fromJsonString(Service.Info)
+const encodeInfo = Schema.encodeEffect(infoJson)
+const decodeInfo = Schema.decodeUnknownEffect(infoJson)
+
+const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
+  const fs = yield* FileSystem.FileSystem
+  const options = yield* ServiceConfig.options()
+  const id = randomUUID()
+  const temp = options.file + "." + id + ".tmp"
+  yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
+  const encoded = yield* encodeInfo({
+    id,
+    version: InstallationVersion,
+    url: HttpServer.formatAddress(address),
+    pid: process.pid,
+    password,
+  })
+  yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
+  yield* fs.rename(temp, options.file)
+  const currentID = fs.readFileString(options.file).pipe(
+    Effect.flatMap(decodeInfo),
+    Effect.map((info) => info.id),
+    Effect.orElseSucceed(() => undefined),
+  )
+  yield* currentID.pipe(
+    Effect.flatMap((current) =>
+      current === id
+        ? Effect.void
+        : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
+    ),
+    Effect.repeat(Schedule.spaced("10 seconds")),
+    Effect.forkScoped,
+  )
+  yield* Effect.addFinalizer(() =>
+    currentID.pipe(
+      Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
+      Effect.ignore,
+    ),
+  )
+})
+
+function waitForStdinClose() {
+  return Effect.callback<void>((resume) => {
+    const close = () => resume(Effect.void)
+    process.stdin.once("end", close)
+    process.stdin.once("close", close)
+    process.stdin.resume()
+    if (process.stdin.readableEnded || process.stdin.destroyed) close()
+    return Effect.sync(() => {
+      process.stdin.off("end", close)
+      process.stdin.off("close", close)
+      process.stdin.pause()
+    })
+  })
+}

+ 15 - 5
packages/cli/src/services/standalone.ts

@@ -9,11 +9,17 @@ import path from "node:path"
 const Ready = Schema.Struct({ url: Schema.String })
 const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
 
-function command(password: string) {
+type Options = {
+  readonly command?: ReadonlyArray<string>
+}
+
+function command(password: string, options: Options) {
   const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
   const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
   if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
-  return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
+  const [executable, ...args] = options.command ?? [process.execPath, ...entrypoint, "serve"]
+  if (!executable) throw new Error("Failed to resolve standalone server command")
+  return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
     cwd: process.cwd(),
     // Explicit entry wins over anything inherited, so a user-exported
     // OPENCODE_PASSWORD cannot shadow the child's lease credential.
@@ -28,11 +34,11 @@ function command(password: string) {
   })
 }
 
-export const transport = Effect.fn("cli.standalone.transport")(
-  function* () {
+const makeTransport = Effect.fn("cli.standalone.transport")(
+  function* (options: Options) {
     const password = randomBytes(32).toString("base64url")
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-    const proc = yield* spawner.spawn(command(password))
+    const proc = yield* spawner.spawn(command(password, options))
     const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
     if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
     const ready = yield* Effect.tryPromise(() => decodeReady(output))
@@ -41,4 +47,8 @@ export const transport = Effect.fn("cli.standalone.transport")(
   Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
 )
 
+export function transport(options: Options = {}) {
+  return makeTransport(options)
+}
+
 export * as Standalone from "./standalone"

+ 80 - 0
packages/cli/test/mini.test.ts

@@ -0,0 +1,80 @@
+import { describe, expect, test } from "bun:test"
+import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
+
+async function cli(args: string[]) {
+  const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
+    cwd: new URL("..", import.meta.url).pathname,
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+  const [stdout, stderr, exitCode] = await Promise.all([
+    new Response(child.stdout).text(),
+    new Response(child.stderr).text(),
+    child.exited,
+  ])
+  return { stdout, stderr, exitCode }
+}
+
+describe("mini command", () => {
+  test("uses piped stdin as the initial prompt", () => {
+    expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
+    expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
+  })
+
+  test("keeps run as mini's non-interactive input mode", () => {
+    expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
+    expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
+  })
+
+  test("applies a variant to a resumed session's model", () => {
+    expect(
+      pickRunModel(
+        undefined,
+        "high",
+        { providerID: "session-provider", modelID: "session-model" },
+        { providerID: "default-provider", modelID: "default-model" },
+      ),
+    ).toEqual({ providerID: "session-provider", modelID: "session-model" })
+  })
+
+  test("is registered in the preview CLI", async () => {
+    const result = await cli(["--help"])
+
+    expect(result.exitCode).toBe(0)
+    expect(result.stdout).toContain("mini       Start the minimal interactive interface")
+    expect(result.stdout).toContain("run        Run OpenCode with a message")
+  })
+
+  test("exposes run without legacy attach or command modes", async () => {
+    const result = await cli(["run", "--help"])
+
+    expect(result.exitCode).toBe(0)
+    expect(result.stdout).toContain("--server string")
+    expect(result.stdout).not.toContain("--attach")
+    expect(result.stdout).not.toContain("--command")
+  })
+
+  test("keeps option-like prompt text after the argument separator", async () => {
+    const result = await cli(["run", "--server", "http://127.0.0.1:1", "--", "--foo"])
+
+    expect(result.exitCode).toBe(1)
+    expect(result.stderr).not.toContain("You must provide a message")
+  })
+
+  test("uses the shared V2 server option instead of an attach command", async () => {
+    const result = await cli(["mini", "--help"])
+
+    expect(result.exitCode).toBe(0)
+    expect(result.stdout).toContain("--server string")
+    expect(result.stdout).not.toContain("SUBCOMMANDS")
+  })
+
+  test("routes local and explicit-server invocations into mini", async () => {
+    for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
+      const result = await cli(args)
+
+      expect(result.exitCode).toBe(1)
+      expect(result.stderr).toContain("opencode mini requires a TTY stdout")
+    }
+  })
+})

+ 33 - 11
packages/client/src/effect/service.ts

@@ -31,11 +31,14 @@ export type Options = {
 // Read-only lookup: registration file plus health check and version gate.
 // Never spawns; escalation to start() is the caller's policy.
 export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
+  return (yield* discoverLocal(options))?.transport
+})
+
+const discoverLocal = Effect.fnUntraced(function* (options: Options) {
   const info = yield* read(options.file)
   if (info === undefined) return undefined
   if (options.version !== undefined && info.version !== options.version) return undefined
-  const found = yield* probe(info)
-  return found?.transport
+  return yield* probe(info, options.version)
 })
 
 // Idempotent ensure-running: reuses a healthy compatible server, replaces a
@@ -48,18 +51,29 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
 
   const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
   if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
-  yield* Effect.try({
+  const child = yield* Effect.try({
     try: () => {
-      spawn(command, args, { detached: true, stdio: "ignore" }).unref()
+      const child = spawn(command, args, { detached: true, stdio: "ignore" })
+      child.unref()
+      return child
     },
     catch: (cause) => new Error("Failed to start server", { cause }),
   })
 
-  return yield* discover(options).pipe(
+  return yield* discoverLocal(options).pipe(
     Effect.flatMap((found) =>
       found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
     ),
     Effect.retry(poll),
+    Effect.tap((found) =>
+      found.info.pid === child.pid
+        ? Effect.void
+        : Effect.sync(() => {
+            child.kill("SIGTERM")
+          }),
+    ),
+    Effect.map((found) => found.transport),
+    Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
     Effect.mapError(() => new Error("Failed to start server")),
   )
 })
@@ -90,6 +104,9 @@ export const Info = Schema.Struct({
 export type Info = typeof Info.Type
 
 const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
+const decodeHealth = Schema.decodeUnknownEffect(
+  Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }),
+)
 
 // A missing or corrupt file means no valid info; callers treat both
 // the same (the registering server self-evicts, clients rediscover).
@@ -105,18 +122,23 @@ type LocalService = {
   readonly transport: Transport
 }
 
-const probe = Effect.fnUntraced(function* (info: Info) {
+const probe = Effect.fnUntraced(function* (info: Info, version?: string) {
   const headers = info.password === undefined ? undefined : auth(info.password)
-  const healthy = yield* Effect.tryPromise(() =>
+  const response = yield* Effect.tryPromise(() =>
     fetch(new URL("/api/health", info.url), {
       headers,
       signal: AbortSignal.timeout(2_000),
     }),
-  ).pipe(
-    Effect.map((response) => response.ok),
-    Effect.orElseSucceed(() => false),
+  ).pipe(Effect.option, Effect.map(Option.getOrUndefined))
+  if (response === undefined || !response.ok) return undefined
+  const health = yield* Effect.tryPromise(() => response.json()).pipe(
+    Effect.flatMap(decodeHealth),
+    Effect.option,
+    Effect.map(Option.getOrUndefined),
   )
-  if (!healthy) return undefined
+  if (health?.pid !== info.pid) return undefined
+  if (info.version !== undefined && health.version !== info.version) return undefined
+  if (version !== undefined && health.version !== version) return undefined
   return { info, transport: { url: info.url, headers } } satisfies LocalService
 })
 

+ 1 - 1
packages/client/src/promise/generated/types.ts

@@ -157,7 +157,7 @@ export type ProjectCopyError = {
 export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
   typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
 
-export type HealthGetOutput = { readonly healthy: true }
+export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
 
 export type LocationGetInput = {
   readonly location?: {

+ 1 - 0
packages/opencode/package.json

@@ -85,6 +85,7 @@
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",
     "@opencode-ai/client": "workspace:*",
+    "@opencode-ai/cli": "workspace:*",
     "@opencode-ai/llm": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/protocol": "workspace:*",

+ 0 - 172
packages/opencode/src/cli/cmd/mini.ts

@@ -1,172 +0,0 @@
-import type { Argv } from "yargs"
-import { cmd } from "./cmd"
-import { UI } from "@/cli/ui"
-import { resolveThreadDirectory } from "./tui"
-
-type ReplayArgs = {
-  replay?: boolean
-  noReplay?: boolean
-}
-
-type MiniArgs = ReplayArgs & {
-  continue?: boolean
-  session?: string
-  fork?: boolean
-  replayLimit?: number
-}
-
-type MiniLocalArgs = MiniArgs & {
-  project?: string
-  model?: string
-  agent?: string
-  prompt?: string
-  demo?: boolean
-}
-
-type MiniAttachArgs = MiniArgs & {
-  url: string
-  dir?: string
-  password?: string
-  username?: string
-}
-
-function replay(args: ReplayArgs) {
-  if (args.replay === true) {
-    UI.error("--replay is not supported; replay is enabled by default")
-    process.exitCode = 1
-    return "invalid" as const
-  }
-  return args.replay === false || args.noReplay === true ? false : undefined
-}
-
-function miniOptions<T>(yargs: Argv<T>) {
-  return yargs
-    .option("continue", {
-      alias: ["c"],
-      describe: "continue the last session",
-      type: "boolean",
-    })
-    .option("session", {
-      alias: ["s"],
-      describe: "session id to continue",
-      type: "string",
-    })
-    .option("fork", {
-      type: "boolean",
-      describe: "fork the session when continuing (use with --continue or --session)",
-    })
-    .option("replay", {
-      type: "boolean",
-      hidden: true,
-    })
-    .option("no-replay", {
-      type: "boolean",
-      describe: "disable session history replay on resume and after resize",
-    })
-    .option("replay-limit", {
-      type: "number",
-      describe: "cap visible replay to the newest N messages",
-    })
-}
-
-/** @internal Exported for CLI parser tests. */
-export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
-  command: "$0 [project]",
-  describe: "start the minimal interactive interface",
-  builder: (yargs) =>
-    miniOptions(
-      yargs
-        .positional("project", {
-          type: "string",
-          describe: "path to start opencode in",
-        })
-        .option("model", {
-          type: "string",
-          alias: ["m"],
-          describe: "model to use in the format of provider/model",
-        })
-        .option("agent", {
-          type: "string",
-          describe: "agent to use",
-        })
-        .option("prompt", {
-          type: "string",
-          describe: "prompt to use",
-        })
-        .option("demo", {
-          type: "boolean",
-          hidden: true,
-        }),
-    ),
-  handler: async (args) => {
-    const shouldReplay = replay(args)
-    if (shouldReplay === "invalid") return
-
-    const { runMini } = await import("./run")
-    await runMini({
-      directory: resolveThreadDirectory(args.project),
-      continue: args.continue,
-      session: args.session,
-      fork: args.fork,
-      model: args.model,
-      agent: args.agent,
-      prompt: args.prompt,
-      replay: shouldReplay,
-      replayLimit: args.replayLimit,
-      demo: args.demo,
-    })
-  },
-})
-
-/** @internal Exported for CLI parser tests. */
-export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
-  command: "attach <url>",
-  describe: "attach to a running opencode server with the minimal interface",
-  builder: (yargs) =>
-    miniOptions(
-      yargs
-        .positional("url", {
-          type: "string",
-          describe: "http://localhost:4096",
-          demandOption: true,
-        })
-        .option("dir", {
-          type: "string",
-          describe: "directory on the remote server",
-        })
-        .option("password", {
-          alias: ["p"],
-          type: "string",
-          describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
-        })
-        .option("username", {
-          alias: ["u"],
-          type: "string",
-          describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
-        }),
-    ),
-  handler: async (args) => {
-    const shouldReplay = replay(args)
-    if (shouldReplay === "invalid") return
-
-    const { runMini } = await import("./run")
-    await runMini({
-      attach: args.url,
-      directory: args.dir,
-      password: args.password,
-      username: args.username,
-      continue: args.continue,
-      session: args.session,
-      fork: args.fork,
-      replay: shouldReplay,
-      replayLimit: args.replayLimit,
-    })
-  },
-})
-
-export const MiniCommand = cmd({
-  command: "mini",
-  describe: "start the minimal interactive interface",
-  builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
-  handler: async () => {},
-})

+ 36 - 1064
packages/opencode/src/cli/cmd/run.ts

@@ -1,149 +1,12 @@
-import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-// CLI entry point for `opencode run` and `opencode mini`.
-//
-// Handles three modes:
-//   1. Non-interactive (default): sends a single prompt, streams events to
-//      stdout, and exits when the session goes idle.
-//   2. Interactive local (`opencode mini`): boots the split-footer direct mode
-//      with an in-process server (no external HTTP).
-//   3. Interactive attach (`opencode mini attach`): connects to a running
-//      opencode server and runs interactive mode against it.
-//
-// Also supports `--command` for slash-command execution, `--format json` for
-// raw event streaming, `--continue` / `--session` for session resumption,
-// and `--fork` for forking before continuing.
-import type { Argv } from "yargs"
-import path from "path"
-import { pathToFileURL } from "url"
-import { open } from "node:fs/promises"
 import { Effect } from "effect"
-import { UI } from "../ui"
 import { effectCmd } from "../effect-cmd"
-import { EOL } from "os"
-import { Filesystem } from "@/util/filesystem"
-import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
-import { FormatError, FormatUnknownError } from "../error"
-import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
-import { isImageAttachment, isPdfAttachment } from "@/util/media"
-import { loadRunAgents, waitForCatalogReady } from "./run/catalog.shared"
-
-type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
-
-function pick(value: string | undefined): ModelInput | undefined {
-  if (!value) return undefined
-  const [providerID, ...rest] = value.split("/")
-  return {
-    providerID,
-    modelID: rest.join("/"),
-  } as ModelInput
-}
-
-function resolveRunInput(value?: string, piped?: string): string | undefined {
-  if (!value) {
-    return piped
-  }
-
-  if (!piped) {
-    return value
-  }
-
-  return value + "\n" + piped
-}
-
-function isBinaryContent(bytes: Uint8Array) {
-  if (bytes.length === 0) return false
-  if (bytes.includes(0)) return true
-  return (
-    bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
-  )
-}
-
-type FilePart = {
-  type: "file"
-  url: string
-  filename: string
-  mime: string
-}
-
-const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
-
-type Inline = {
-  icon: string
-  title: string
-  description?: string
-}
-
-type SessionInfo = {
-  id: string
-  title?: string
-  directory?: string
-  current?: boolean
-}
-
-function inline(info: Inline) {
-  const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
-  UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
-}
-
-function block(info: Inline, output?: string) {
-  UI.empty()
-  inline(info)
-  if (!output?.trim()) return
-  UI.println(output)
-  UI.empty()
-}
-
-function formatRunError(error: unknown) {
-  return FormatError(error) ?? FormatUnknownError(error)
-}
-
-async function tool(part: ToolPart) {
-  try {
-    const { toolInlineInfo } = await import("./run/tool")
-    const next = toolInlineInfo(part)
-    if (next.mode === "block") {
-      block(next, next.body)
-      return
-    }
-
-    inline(next)
-  } catch {
-    inline({
-      icon: "\u2699",
-      title: part.tool,
-    })
-  }
-}
-
-async function toolError(part: ToolPart) {
-  try {
-    const { toolInlineInfo } = await import("./run/tool")
-    const next = toolInlineInfo(part)
-    inline({
-      icon: "✗",
-      title: `${next.title} failed`,
-      ...(next.description && { description: next.description }),
-    })
-    return
-  } catch {
-    inline({
-      icon: "✗",
-      title: `${part.tool} failed`,
-    })
-  }
-}
+import { v2ServerCommand } from "./v2-server-command"
 
 export const RunCommand = effectCmd({
   command: "run [message..]",
   describe: "run opencode with a message",
-  // --attach connects to a remote server (no local instance needed); the
-  // default path runs an in-process server and needs the project instance.
-  instance: (args) => !args.attach,
-  // For --dir without --attach, load instance for the resolved target dir.
-  // The handler also chdirs (preserving the legacy order: chdir → file resolution).
-  directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
-  builder: (yargs: Argv) =>
+  instance: false,
+  builder: (yargs) =>
     yargs
       .positional("message", {
         describe: "message to send",
@@ -151,10 +14,6 @@ export const RunCommand = effectCmd({
         array: true,
         default: [],
       })
-      .option("command", {
-        describe: "the command to run, use message for args",
-        type: "string",
-      })
       .option("continue", {
         alias: ["c"],
         describe: "continue the last session",
@@ -180,8 +39,8 @@ export const RunCommand = effectCmd({
       })
       .option("format", {
         type: "string",
-        choices: ["default", "json"],
-        default: "default",
+        choices: ["default", "json"] as const,
+        default: "default" as const,
         describe: "format: default (formatted) or json (raw JSON events)",
       })
       .option("file", {
@@ -194,53 +53,36 @@ export const RunCommand = effectCmd({
         type: "string",
         describe: "title for the session (uses truncated prompt if no value provided)",
       })
+      .option("server", {
+        type: "string",
+        describe: "connect to a running opencode server",
+      })
       .option("attach", {
         type: "string",
-        describe: "attach to a running opencode server (e.g., http://localhost:4096)",
+        hidden: true,
       })
       .option("password", {
         alias: ["p"],
         type: "string",
-        describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
+        hidden: true,
       })
       .option("username", {
         alias: ["u"],
         type: "string",
-        describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
+        hidden: true,
       })
       .option("dir", {
         type: "string",
-        describe: "directory to run in, path on remote server if attaching",
-      })
-      .option("port", {
-        type: "number",
-        describe: "port for the local server (defaults to random port if no value provided)",
+        describe: "directory to run in, or a path on the remote server",
       })
       .option("variant", {
         type: "string",
-        describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
+        describe: "model variant",
       })
       .option("thinking", {
         type: "boolean",
         describe: "show thinking blocks",
       })
-      .option("replay", {
-        type: "boolean",
-        default: true,
-        hidden: true,
-        describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
-      })
-      .option("replay-limit", {
-        type: "number",
-        hidden: true,
-        describe: "cap visible interactive replay to the newest N messages",
-      })
-      .option("interactive", {
-        alias: ["i"],
-        type: "boolean",
-        describe: "run in direct interactive split-footer mode",
-        default: false,
-      })
       .option("auto", {
         type: "boolean",
         describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
@@ -255,899 +97,29 @@ export const RunCommand = effectCmd({
         type: "boolean",
         hidden: true,
         default: false,
-      })
-      .option("demo", {
-        type: "boolean",
-        default: false,
-        hidden: true,
-        describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
       }),
   handler: Effect.fn("Cli.run")(function* (args) {
-    const { Agent } = yield* Effect.promise(() => import("@/agent/agent"))
-    const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags"))
-    const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
-    const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth"))
-    const agentSvc = yield* Agent.Service
-    const flags = yield* RuntimeFlags.Service
-    const localInstance = yield* InstanceRef
-    yield* Effect.promise(async () => {
-      const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
-      const interactive = (args as typeof args & { mini?: boolean }).mini === true
-      const auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
-      const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
-      const die = (message: string): never => {
-        UI.error(message)
-        process.exit(1)
-      }
-      const dieInteractive = (error: unknown): never => {
-        if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
-          die(error.message)
-        }
-
-        throw error
-      }
-
-      let message = [...args.message, ...(args["--"] || [])]
-        .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
-        .join(" ")
-
-      if (interactive && args.command) {
-        die("opencode mini cannot be used with --command")
-      }
-
-      if (interactive && args._?.[0] !== "mini") {
-        die("opencode mini must be run with the mini command")
-      }
-
-      if (args.demo && !interactive) {
-        die("--demo requires opencode mini")
-      }
-
-      if (interactive && args.format === "json") {
-        die("opencode mini cannot be used with --format json")
-      }
-
-      if (args["replay-limit"] !== undefined && !interactive) {
-        die("--replay-limit requires opencode mini")
-      }
-
-      if (
-        args["replay-limit"] !== undefined &&
-        (!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
-      ) {
-        die("--replay-limit must be a positive integer")
-      }
-
-      if (interactive && !process.stdout.isTTY) {
-        die("opencode mini requires a TTY stdout")
-      }
-
-      if (interactive) {
-        try {
-          resolveInteractiveStdin().cleanup?.()
-        } catch (error) {
-          dieInteractive(error)
-        }
-      }
-
-      const replay = args.replay === false ? false : args.replay || args["replay-limit"] !== undefined
-
-      const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
-      const directory = (() => {
-        if (!args.dir) return args.attach ? undefined : root
-        if (args.attach) return args.dir
-
-        try {
-          process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
-          return process.cwd()
-        } catch {
-          UI.error("Failed to change directory to " + args.dir)
-          process.exit(1)
-        }
-      })()
-      const attachHeaders = args.attach
-        ? ServerAuth.headers({ password: args.password, username: args.username })
-        : undefined
-      const attachSDK = (dir?: string) => {
-        return createOpencodeClient({
-          baseUrl: args.attach!,
-          directory: dir,
-          headers: attachHeaders,
-        })
-      }
-
-      const files: FilePart[] = []
-      const fileInputs: Array<{
-        filePath: string
-        resolvedPath: string
-        stat: ReturnType<typeof Filesystem.stat>
-        isDirectory: boolean
-      }> = []
-      if (args.file) {
-        const list = Array.isArray(args.file) ? args.file : [args.file]
-
-        for (const filePath of list) {
-          const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
-          if (!(await Filesystem.exists(resolvedPath))) {
-            UI.error(`File not found: ${filePath}`)
-            process.exit(1)
-          }
-
-          const stat = Filesystem.stat(resolvedPath)
-          const isDirectory = stat?.isDirectory() ?? false
-          if (args.attach && isDirectory) {
-            UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
-            process.exit(1)
-          }
-          fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
-        }
-      }
-
-      const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
-      message = resolveRunInput(message, piped) ?? ""
-      const initialInput = resolveRunInput(rawMessage, piped)
-
-      if (message.trim().length === 0 && !args.command && !interactive) {
-        UI.error("You must provide a message or a command")
-        process.exit(1)
-      }
-
-      if (args.fork && !args.continue && !args.session) {
-        UI.error("--fork requires --continue or --session")
-        process.exit(1)
-      }
-
-      const rules: PermissionV1.Ruleset = interactive
-        ? []
-        : [
-          {
-            permission: "question",
-            action: "deny",
-            pattern: "*",
-          },
-          {
-            permission: "plan_enter",
-            action: "deny",
-            pattern: "*",
-          },
-          {
-            permission: "plan_exit",
-            action: "deny",
-            pattern: "*",
-          },
-        ]
-      const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory)
-
-      const inlineFiles = interactive || currentPrompt
-      for (const file of fileInputs) {
-        const content = await (async () => {
-          if (file.isDirectory || !inlineFiles) return
-          if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) {
-            UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
-            process.exit(1)
-          }
-          const handle = await open(file.resolvedPath, "r")
-          try {
-            const opened = await handle.stat()
-            if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
-              UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
-              process.exit(1)
-            }
-            if (opened.size === 0) return Buffer.alloc(0)
-            const buffer = Buffer.alloc(Number(opened.size))
-            let offset = 0
-            while (offset < buffer.length) {
-              const read = await handle.read(buffer, offset, buffer.length - offset, offset)
-              if (read.bytesRead === 0) break
-              offset += read.bytesRead
-            }
-            return buffer.subarray(0, offset)
-          } finally {
-            await handle.close()
-          }
-        })()
-        const detected = FSUtil.mimeType(file.resolvedPath)
-        const text = content?.toString("utf8")
-        const mime = file.isDirectory
-          ? "application/x-directory"
-          : isImageAttachment(detected) || isPdfAttachment(detected)
-            ? detected
-            : content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content)
-              ? "text/plain"
-              : detected
-
-        files.push({
-          type: "file",
-          url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href,
-          filename: path.basename(file.resolvedPath),
-          mime,
-        })
-      }
-
-      function title() {
-        if (args.title === undefined) return
-        if (args.title !== "") return args.title
-        return message.slice(0, 50) + (message.length > 50 ? "..." : "")
-      }
-
-      async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
-        const listed = await sdk.v2.session
-          .list({
-            directory: await current(sdk),
-            limit: 50,
-            order: "desc",
-          })
-          .then((result) => result.data?.data.find((item) => item.id === sessionID))
-          .catch(() => undefined)
-        const selected =
-          listed ??
-          (await sdk.v2.session
-            .get({ sessionID })
-            .then((result) => result.data?.data)
-            .catch(() => undefined))
-        const legacy =
-          selected ??
-          (await sdk.session
-            .get({ sessionID })
-            .then((result) => result.data)
-            .catch(() => undefined))
-        const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID)
-        if (!legacy && transcript === "empty") {
-          return
-        }
-        if (interactive && transcript === "legacy") {
-          throw new Error("Mini cannot resume a legacy Session transcript")
-        }
-
-        return {
-          id: legacy?.id ?? sessionID,
-          title: legacy?.title,
-          directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
-          current: transcript !== "legacy",
-        }
-      }
-
-      async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise<SessionInfo | undefined> {
-        if (session.current !== false) {
-          const forked = await sdk.v2.session.fork(
-            { sessionID: session.id, messageID: undefined },
-            { throwOnError: true },
-          )
-          await waitForFork(sdk, session.id, forked.data.data.id)
-          return {
-            id: forked.data.data.id,
-            title: forked.data.data.title,
-            directory: forked.data.data.location.directory,
-            current: true,
-          }
-        }
-
-        const forked = await sdk.session.fork({
-          sessionID: session.id,
-        })
-        const id = forked.data?.id
-        if (!id) {
-          return
-        }
-
-        return {
-          id,
-          title: forked.data?.title ?? session.title,
-          directory: forked.data?.directory ?? session.directory,
-          current: false,
-        }
-      }
-
-      async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) {
-        const parentHasMessages = await sdk.v2.session
-          .messages({ sessionID: parentID, limit: 1 })
-          .then((result) => (result.data?.data.length ?? 0) > 0)
-          .catch(() => false)
-        if (!parentHasMessages) {
-          return
-        }
-
-        const deadline = Date.now() + 3000
-        while (Date.now() < deadline) {
-          const forkedHasMessages = await sdk.v2.session
-            .messages({ sessionID, limit: 1 })
-            .then((result) => (result.data?.data.length ?? 0) > 0)
-            .catch(() => false)
-          if (forkedHasMessages) {
-            return
-          }
-
-          await Bun.sleep(25)
-        }
-      }
-
-      async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
-        if (args.session) {
-          const current = await currentSession(sdk, args.session)
-          if (!current) {
-            UI.error("Session not found")
-            process.exit(1)
-          }
-          if (!interactive && !currentPrompt && current.current !== false) {
-            throw new Error("This operation is not available for a current Session transcript")
-          }
-
-          if (args.fork) {
-            return forkSession(sdk, current)
-          }
-
-          return current
-        }
-
-        const base = args.continue ? await currentRootSession(sdk) : undefined
-        if (base && !interactive && !currentPrompt && base.current !== false) {
-          throw new Error("This operation is not available for a current Session transcript")
-        }
-
-        if (base && args.fork) {
-          return forkSession(sdk, base)
-        }
-
-        if (base) {
-          return {
-            id: base.id,
-            title: base.title,
-            directory: base.directory,
-            current: "current" in base ? base.current : false,
-          }
-        }
-
-        if (interactive || currentPrompt) {
-          const name = title()
-          const result = await sdk.v2.session.create({
-            location: { directory: await current(sdk) },
-          })
-          const created = result.data?.data
-          if (!created) return
-          if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name })
-          return {
-            id: created.id,
-            title: name ?? created.title,
-            directory: created.location.directory,
-            current: true,
-          }
-        }
-
-        const name = title()
-        const result = await sdk.session.create({
-          title: name,
-          permission: [...rules],
-        })
-        const id = result.data?.id
-        if (!id) {
-          return
-        }
-
-        return {
-          id,
-          title: result.data?.title ?? name,
-          directory: result.data?.directory,
-          current: false,
-        }
-      }
-
-      async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
-        const response = await sdk.v2.session.list({
-          directory: await current(sdk),
-          limit: 50,
-          order: "desc",
-        })
-        const root = (response.data?.data ?? [])
-          .filter((session) => !session.parentID)
-          .toSorted((a, b) => b.time.updated - a.time.updated)[0]
-        if (!root) return
-        return currentSession(sdk, root.id)
-      }
-
-      async function transcriptKind(sdk: OpencodeClient, sessionID: string) {
-        const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0)
-        // Ordinary prompt flows assume a transcript with current messages is
-        // current-owned; only legacy-only modes (--command, directory
-        // attachments) still probe legacy history for mixed transcripts.
-        if (current && (interactive || currentPrompt)) return "current" as const
-
-        const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0)
-        if (current) {
-          if (legacy) throw new Error("Session contains mixed legacy and current transcripts")
-          return "current" as const
-        }
-        if (legacy) return "legacy" as const
-        return "empty" as const
-      }
-
-      async function createFreshSession(
-        sdk: OpencodeClient,
-        input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
-      ): Promise<SessionInfo> {
-        const name = args.title !== undefined && args.title !== "" ? args.title : undefined
-        const result = await sdk.v2.session.create({
-          agent: input.agent,
-          model: input.model
-            ? {
-              providerID: input.model.providerID,
-              id: input.model.modelID,
-              variant: input.variant,
-            }
-            : undefined,
-          location: { directory: await current(sdk) },
-        })
-        const created = result.data?.data
-        const id = created?.id
-        if (!id) {
-          throw new Error("Failed to create session")
-        }
-        if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
-
-        return {
-          id,
-          title: name ?? created.title,
-        }
-      }
-
-      async function current(sdk: OpencodeClient): Promise<string> {
-        if (!args.attach) {
-          return directory ?? root
-        }
-
-        const next = await sdk.v2.location
-          .get(undefined, { throwOnError: true })
-          .then((x) => x.data?.directory)
-          .catch(() => undefined)
-        if (next) {
-          return next
-        }
-
-        UI.error("Failed to resolve remote directory")
-        process.exit(1)
-      }
-
-      async function localAgent() {
-        if (!args.agent) return undefined
-        const name = args.agent
-
-        const entry = await Effect.runPromise(
-          agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
-        )
-        if (!entry) {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" not found. Falling back to default agent`,
-          )
-          return undefined
-        }
-        if (entry.mode === "subagent") {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
-          )
-          return undefined
-        }
-        return name
-      }
-
-      async function attachAgent(sdk: OpencodeClient) {
-        if (!args.agent) return undefined
-        const name = args.agent
-
-        const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
-
-        if (!modes) {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `failed to list agents from ${args.attach}. Falling back to default agent`,
-          )
-          return undefined
-        }
-
-        const agent = modes.find((item) => item.name === name)
-        if (!agent) {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" not found. Falling back to default agent`,
-          )
-          return undefined
-        }
-
-        if (agent.mode === "subagent") {
-          UI.println(
-            UI.Style.TEXT_WARNING_BOLD + "!",
-            UI.Style.TEXT_NORMAL,
-            `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
-          )
-          return undefined
-        }
-
-        return name
-      }
-
-      async function pickAgent(sdk: OpencodeClient) {
-        if (!args.agent) return undefined
-        if (args.attach) {
-          return attachAgent(sdk)
-        }
-
-        return localAgent()
-      }
-
-      async function execute(sdk: OpencodeClient) {
-        const sess = await session(sdk)
-        if (!sess?.id) {
-          UI.error("Session not found")
-          process.exit(1)
-        }
-        const sessionID = sess.id
-
-        function emit(type: string, data: Record<string, unknown>) {
-          if (args.format === "json") {
-            process.stdout.write(
-              JSON.stringify({
-                type,
-                timestamp: Date.now(),
-                sessionID,
-                ...data,
-              }) + EOL,
-            )
-            return true
-          }
-          return false
-        }
-
-        // Consume one subscribed event stream for the active session and mirror it
-        // to stdout/UI. `client` is passed explicitly because attach mode may
-        // rebind the SDK to the session's directory after the subscription is
-        // created, and replies issued from inside the loop must use that client.
-        async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
-          const toggles = new Map<string, boolean>()
-          let error: string | undefined
-
-          for await (const event of events.stream) {
-            if (
-              event.type === "message.updated" &&
-              event.properties.sessionID === sessionID &&
-              event.properties.info.role === "assistant" &&
-              args.format !== "json" &&
-              toggles.get("start") !== true
-            ) {
-              UI.empty()
-              UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
-              UI.empty()
-              toggles.set("start", true)
-            }
-
-            if (event.type === "message.part.updated") {
-              const part = event.properties.part
-              if (part.sessionID !== sessionID) continue
-
-              if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
-                if (emit("tool_use", { part })) continue
-                if (part.state.status === "completed") {
-                  await tool(part)
-                  continue
-                }
-                await toolError(part)
-                UI.error(part.state.error)
-              }
-
-              if (
-                part.type === "tool" &&
-                part.tool === "task" &&
-                part.state.status === "running" &&
-                args.format !== "json"
-              ) {
-                if (toggles.get(part.id) === true) continue
-                await tool(part)
-                toggles.set(part.id, true)
-              }
-
-              if (part.type === "step-start") {
-                if (emit("step_start", { part })) continue
-              }
-
-              if (part.type === "step-finish") {
-                if (emit("step_finish", { part })) continue
-              }
-
-              if (part.type === "text" && part.time?.end) {
-                if (emit("text", { part })) continue
-                const text = part.text.trim()
-                if (!text) continue
-                if (!process.stdout.isTTY) {
-                  process.stdout.write(text + EOL)
-                  continue
-                }
-                UI.empty()
-                UI.println(text)
-                UI.empty()
-              }
-
-              if (part.type === "reasoning" && part.time?.end && thinking) {
-                if (emit("reasoning", { part })) continue
-                const text = part.text.trim()
-                if (!text) continue
-                const line = `Thinking: ${text}`
-                if (process.stdout.isTTY) {
-                  UI.empty()
-                  UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
-                  UI.empty()
-                  continue
-                }
-                process.stdout.write(line + EOL)
-              }
-            }
-
-            if (event.type === "session.error") {
-              const props = event.properties
-              if (props.sessionID !== sessionID || !props.error) continue
-              let err = String(props.error.name)
-              if ("data" in props.error && props.error.data && "message" in props.error.data) {
-                err = String(props.error.data.message)
-              }
-              error = error ? error + EOL + err : err
-              if (emit("error", { error: props.error })) continue
-              UI.error(err)
-            }
-
-            if (
-              event.type === "session.status" &&
-              event.properties.sessionID === sessionID &&
-              event.properties.status.type === "idle"
-            ) {
-              break
-            }
-
-            if (event.type === "permission.asked") {
-              const permission = event.properties
-              if (permission.sessionID !== sessionID) continue
-
-              if (auto) {
-                await client.permission.reply({
-                  requestID: permission.id,
-                  reply: "once",
-                })
-              } else {
-                UI.println(
-                  UI.Style.TEXT_WARNING_BOLD + "!",
-                  UI.Style.TEXT_NORMAL +
-                  `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
-                )
-                await client.permission.reply({
-                  requestID: permission.id,
-                  reply: "reject",
-                })
-              }
-            }
-          }
-          return error
-        }
-        const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
-        const client = args.attach ? attachSDK(cwd) : sdk
-
-        // Current-session flows resolve explicit --model refs from a location
-        // catalog that populates asynchronously after boot. Wait only when the
-        // user supplied a model; default-model races.
-        const selectedModel = pick(args.model)
-        if (selectedModel && (interactive || (currentPrompt && sess.current !== false))) {
-          await waitForCatalogReady({ sdk: client, directory: cwd, model: selectedModel })
-        }
-
-        // Validate agent if specified
-        const agent = await pickAgent(client)
-
-        if (!interactive) {
-          if (currentPrompt && sess.current !== false) {
-            const model = selectedModel
-            const { runNonInteractivePrompt } = await import("./run/noninteractive")
-            try {
-              await runNonInteractivePrompt({
-                client,
-                sessionID,
-                message,
-                files,
-                agent,
-                model,
-                variant: args.variant,
-                thinking,
-                format: args.format === "json" ? "json" : "default",
-                dangerouslySkipPermissions: args["dangerously-skip-permissions"],
-                attached: Boolean(args.attach),
-                renderTool: tool,
-                renderToolError: toolError,
-              })
-            } catch (error) {
-              const output = error instanceof Error ? { type: "unknown", message: error.message } : error
-              if (!emit("error", { error: output })) UI.error(formatRunError(error))
-              process.exitCode = 1
-            }
-            return
-          }
-
-          const events = await client.event.subscribe()
-          const completed = loop(client, events).catch((e) => {
-            console.error(e)
-            process.exitCode = 1
-          })
-          async function finish() {
-            if (args.attach) return
-            const error = await completed
-            if (error) process.exitCode = 1
-          }
-
-          if (args.command) {
-            const result = await client.session.command({
-              sessionID,
-              agent,
-              model: args.model,
-              command: args.command,
-              arguments: message,
-              variant: args.variant,
-            })
-            if (result.error) {
-              if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
-              process.exitCode = 1
-              return
-            }
-            await finish()
-            return
-          }
-
-          const model = pick(args.model)
-          const result = await client.session.prompt({
-            sessionID,
-            agent,
-            model,
-            variant: args.variant,
-            parts: [...files, { type: "text", text: message }],
-          })
-          if (result.error) {
-            if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
-            process.exitCode = 1
-            return
-          }
-          await finish()
-          return
-        }
-
-        const model = pick(args.model)
-        const { runInteractiveMode } = await import("./run/runtime")
-        try {
-          await runInteractiveMode({
-            sdk: client,
-            directory: cwd,
-            sessionID,
-            sessionTitle: sess.title,
-            resume: Boolean(args.session || args.continue),
-            replay,
-            replayLimit: args["replay-limit"],
-            agent,
-            model,
-            variant: args.variant,
-            files,
-            initialInput,
-            createSession: createFreshSession,
-            thinking,
-            backgroundSubagents: flags.experimentalBackgroundSubagents,
-            demo: args.demo,
-          })
-        } catch (error) {
-          dieInteractive(error)
-        }
-        return
-      }
-
-      if (interactive && !args.attach && !args.session && !args.continue) {
-        const model = pick(args.model)
-        const { runInteractiveLocalMode } = await import("./run/runtime")
-        const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
-          const { Server } = await import("@/server/server")
-          const request = new Request(input, init)
-          const headers = new Headers(request.headers)
-          const auth = ServerAuth.header()
-          if (auth) headers.set("Authorization", auth)
-          return Server.Default().app.fetch(new Request(request, { headers }))
-        }) as typeof globalThis.fetch
-
-        try {
-          return await runInteractiveLocalMode({
-            directory: directory ?? root,
-            fetch: fetchFn,
-            resolveAgent: localAgent,
-            session,
-            createSession: createFreshSession,
-            agent: args.agent,
-            model,
-            variant: args.variant,
-            replay,
-            replayLimit: args["replay-limit"],
-            files,
-            initialInput,
-            thinking,
-            backgroundSubagents: flags.experimentalBackgroundSubagents,
-            demo: args.demo,
-          })
-        } catch (error) {
-          dieInteractive(error)
-        }
-      }
-
-      if (args.attach) {
-        const sdk = attachSDK(directory)
-        return await execute(sdk)
-      }
-
-      const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
-        const { Server } = await import("@/server/server")
-        const request = new Request(input, init)
-        const headers = new Headers(request.headers)
-        const auth = ServerAuth.header()
-        if (auth) headers.set("Authorization", auth)
-        return Server.Default().app.fetch(new Request(request, { headers }))
-      }) as typeof globalThis.fetch
-      const sdk = createOpencodeClient({
-        baseUrl: "http://opencode.internal",
-        fetch: fetchFn,
-        directory,
-      })
-      await execute(sdk)
-    })
+    const { runNonInteractive } = yield* Effect.promise(() => import("@opencode-ai/cli/mini"))
+    yield* Effect.promise(() =>
+      runNonInteractive({
+        message: [...args.message, ...(args["--"] || [])],
+        continue: args.continue,
+        session: args.session,
+        fork: args.fork,
+        model: args.model,
+        agent: args.agent,
+        format: args.format,
+        file: args.file ?? [],
+        title: args.title,
+        server: args.server ?? args.attach,
+        password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
+        username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
+        directory: args.dir,
+        variant: args.variant,
+        thinking: args.thinking,
+        dangerouslySkipPermissions: args.auto || args.yolo || args["dangerously-skip-permissions"],
+        standaloneCommand: args.server || args.attach ? undefined : v2ServerCommand(),
+      }),
+    )
   }),
 })
-
-type MiniCommandInput = {
-  directory?: string
-  attach?: string
-  password?: string
-  username?: string
-  continue?: boolean
-  session?: string
-  fork?: boolean
-  model?: string
-  agent?: string
-  prompt?: string
-  replay?: boolean
-  replayLimit?: number
-  demo?: boolean
-}
-
-export async function runMini(input: MiniCommandInput) {
-  if (!RunCommand.handler) throw new Error("Mini command handler is unavailable")
-  await RunCommand.handler({
-    $0: "opencode",
-    _: ["mini"],
-    message: input.prompt ? [input.prompt] : [],
-    command: undefined,
-    continue: input.continue,
-    session: input.session,
-    fork: input.fork,
-    model: input.model,
-    agent: input.agent,
-    format: "default",
-    file: undefined,
-    title: undefined,
-    attach: input.attach,
-    password: input.password,
-    username: input.username,
-    dir: input.directory,
-    port: undefined,
-    variant: undefined,
-    thinking: undefined,
-    mini: true,
-    interactive: false,
-    replay: input.replay ?? true,
-    "replay-limit": input.replayLimit,
-    replayLimit: input.replayLimit,
-    auto: false,
-    yolo: false,
-    "dangerously-skip-permissions": false,
-    dangerouslySkipPermissions: false,
-    demo: input.demo ?? false,
-  } as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
-}

+ 31 - 0
packages/opencode/src/cli/cmd/v2-serve.ts

@@ -0,0 +1,31 @@
+import { ServerProcess } from "@opencode-ai/cli/server-process"
+import { Effect } from "effect"
+import { cmd } from "./cmd"
+
+export const V2ServeCommand = cmd({
+  command: "__v2-serve",
+  describe: false,
+  builder: (yargs) =>
+    yargs
+      .option("stdio", { type: "boolean", hidden: true })
+      .option("port", { type: "number", hidden: true }),
+  handler: async (args) => {
+    const controller = new AbortController()
+    const interrupt = () => controller.abort()
+    process.once("SIGINT", interrupt)
+    process.once("SIGTERM", interrupt)
+    process.once("SIGHUP", interrupt)
+    try {
+      await Effect.runPromise(
+        ServerProcess.run({ mode: args.stdio ? "stdio" : "service", port: args.port }),
+        { signal: controller.signal },
+      )
+    } catch (error) {
+      if (!controller.signal.aborted) throw error
+    } finally {
+      process.off("SIGINT", interrupt)
+      process.off("SIGTERM", interrupt)
+      process.off("SIGHUP", interrupt)
+    }
+  },
+})

+ 8 - 0
packages/opencode/src/cli/cmd/v2-server-command.ts

@@ -0,0 +1,8 @@
+import path from "node:path"
+
+export function v2ServerCommand() {
+  const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
+  const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
+  if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
+  return [process.execPath, ...entrypoint, "__v2-serve"]
+}

+ 2 - 2
packages/opencode/src/index.ts

@@ -19,7 +19,7 @@ import { GithubCommand } from "./cli/cmd/github"
 import { ExportCommand } from "./cli/cmd/export"
 import { ImportCommand } from "./cli/cmd/import"
 import { AttachCommand } from "./cli/cmd/attach"
-import { MiniCommand } from "./cli/cmd/mini"
+import { V2ServeCommand } from "./cli/cmd/v2-serve"
 import { TuiThreadCommand } from "./cli/cmd/tui"
 import { AcpCommand } from "./cli/cmd/acp"
 import { EOL } from "os"
@@ -81,7 +81,7 @@ const cli = yargs(args)
   .completion("completion", "generate shell completion script")
   .command(AcpCommand)
   .command(McpCommand)
-  .command(MiniCommand)
+  .command(V2ServeCommand)
   .command(TuiThreadCommand)
   .command(AttachCommand)
   .command(RunCommand)

+ 2 - 2
packages/opencode/src/temporary.ts

@@ -1,6 +1,6 @@
 import yargs from "yargs"
-import { MiniCommand } from "./cli/cmd/mini"
 import { TuiThreadCommand } from "./cli/cmd/tui"
+import { V2ServeCommand } from "./cli/cmd/v2-serve"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { hideBin } from "yargs/helpers"
 const cli = yargs(hideBin(process.argv))
@@ -28,6 +28,6 @@ const cli = yargs(hideBin(process.argv))
     if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
     if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
   })
-  .command(MiniCommand)
+  .command(V2ServeCommand)
   .command(TuiThreadCommand)
   .parse()

+ 18 - 79
packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap

@@ -41,34 +41,6 @@ Options:
       --pure        run without external plugins                                           [boolean]"
 `;
 
-exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = `
-"opencode mini
-
-start the minimal interactive interface
-
-Commands:
-  opencode mini [project]     start the minimal interactive interface                      [default]
-  opencode mini attach <url>  attach to a running opencode server with the minimal interface
-
-Positionals:
-  project  path to start opencode in                                                        [string]
-
-Options:
-  -h, --help          show help                                                            [boolean]
-  -v, --version       show version number                                                  [boolean]
-      --print-logs    print logs to stderr                                                 [boolean]
-      --log-level     log level                 [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
-      --pure          run without external plugins                                         [boolean]
-  -m, --model         model to use in the format of provider/model                          [string]
-      --agent         agent to use                                                          [string]
-      --prompt        prompt to use                                                         [string]
-  -c, --continue      continue the last session                                            [boolean]
-  -s, --session       session id to continue                                                [string]
-      --fork          fork the session when continuing (use with --continue or --session)  [boolean]
-      --no-replay     disable session history replay on resume and after resize            [boolean]
-      --replay-limit  cap visible replay to the newest N messages                           [number]"
-`;
-
 exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
 "opencode attach <url>
 
@@ -100,33 +72,25 @@ Positionals:
   message  message to send                                                     [array] [default: []]
 
 Options:
-  -h, --help         show help                                                             [boolean]
-  -v, --version      show version number                                                   [boolean]
-      --print-logs   print logs to stderr                                                  [boolean]
-      --log-level    log level                  [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
-      --pure         run without external plugins                                          [boolean]
-      --command      the command to run, use message for args                               [string]
-  -c, --continue     continue the last session                                             [boolean]
-  -s, --session      session id to continue                                                 [string]
-      --fork         fork the session before continuing (requires --continue or --session) [boolean]
-  -m, --model        model to use in the format of provider/model                           [string]
-      --agent        agent to use                                                           [string]
-      --format       format: default (formatted) or json (raw JSON events)
+  -h, --help        show help                                                              [boolean]
+  -v, --version     show version number                                                    [boolean]
+      --print-logs  print logs to stderr                                                   [boolean]
+      --log-level   log level                   [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
+      --pure        run without external plugins                                           [boolean]
+  -c, --continue    continue the last session                                              [boolean]
+  -s, --session     session id to continue                                                  [string]
+      --fork        fork the session before continuing (requires --continue or --session)  [boolean]
+  -m, --model       model to use in the format of provider/model                            [string]
+      --agent       agent to use                                                            [string]
+      --format      format: default (formatted) or json (raw JSON events)
                                           [string] [choices: "default", "json"] [default: "default"]
-  -f, --file         file(s) to attach to message                                            [array]
-      --title        title for the session (uses truncated prompt if no value provided)     [string]
-      --attach       attach to a running opencode server (e.g., http://localhost:4096)      [string]
-  -p, --password     basic auth password (defaults to OPENCODE_SERVER_PASSWORD)             [string]
-  -u, --username     basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
-                                                                                            [string]
-      --dir          directory to run in, path on remote server if attaching                [string]
-      --port         port for the local server (defaults to random port if no value provided)
-                                                                                            [number]
-      --variant      model variant (provider-specific reasoning effort, e.g., high, max, minimal)
-                                                                                            [string]
-      --thinking     show thinking blocks                                                  [boolean]
-  -i, --interactive  run in direct interactive split-footer mode          [boolean] [default: false]
-      --auto         auto-approve permissions that are not explicitly denied (dangerous!)
+  -f, --file        file(s) to attach to message                                             [array]
+      --title       title for the session (uses truncated prompt if no value provided)      [string]
+      --server      connect to a running opencode server                                    [string]
+      --dir         directory to run in, or a path on the remote server                     [string]
+      --variant     model variant                                                           [string]
+      --thinking    show thinking blocks                                                   [boolean]
+      --auto        auto-approve permissions that are not explicitly denied (dangerous!)
                                                                           [boolean] [default: false]"
 `;
 
@@ -426,31 +390,6 @@ Options:
       --format      Output format                 [string] [choices: "json", "tsv"] [default: "tsv"]"
 `;
 
-exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = `
-"opencode mini attach <url>
-
-attach to a running opencode server with the minimal interface
-
-Positionals:
-  url  http://localhost:4096                                                     [string] [required]
-
-Options:
-  -h, --help          show help                                                            [boolean]
-  -v, --version       show version number                                                  [boolean]
-      --print-logs    print logs to stderr                                                 [boolean]
-      --log-level     log level                 [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
-      --pure          run without external plugins                                         [boolean]
-      --dir           directory on the remote server                                        [string]
-  -p, --password      basic auth password (defaults to OPENCODE_SERVER_PASSWORD)            [string]
-  -u, --username      basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')
-                                                                                            [string]
-  -c, --continue      continue the last session                                            [boolean]
-  -s, --session       session id to continue                                                [string]
-      --fork          fork the session when continuing (use with --continue or --session)  [boolean]
-      --no-replay     disable session history replay on resume and after resize            [boolean]
-      --replay-limit  cap visible replay to the newest N messages                           [number]"
-`;
-
 exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
 "opencode mcp list
 

+ 1 - 3
packages/opencode/test/cli/help/help-snapshots.test.ts

@@ -57,7 +57,6 @@ function normalize(text: string): string {
 const TOP_LEVEL = [
   "acp",
   "mcp",
-  "mini",
   "attach",
   "run",
   "debug",
@@ -82,7 +81,6 @@ const TOP_LEVEL = [
 // distinct argv shape, not every leaf. Add new entries when a subcommand
 // gains user-visible flags that we want to lock in.
 const SUBCOMMANDS = [
-  ["mini", "attach"],
   ["mcp", "list"],
   ["mcp", "add"],
   ["mcp", "auth"],
@@ -115,7 +113,7 @@ describe("opencode CLI help-text snapshots", () => {
         const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV })
         expect(topLevel.exitCode).toBe(0)
         expect(topLevel.stderr.endsWith("\n")).toBe(true)
-        expect(topLevel.stderr).toContain("opencode mini")
+        expect(topLevel.stderr).not.toContain("opencode mini")
         expect(topLevel.stderr).not.toContain("--mini")
         expect(topLevel.stderr).not.toContain("--thinking")
         expect(topLevel.stderr).not.toContain("--variant")

+ 13 - 26
packages/opencode/test/cli/run/catalog.shared.test.ts

@@ -1,6 +1,6 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { OpencodeClient } from "@opencode-ai/sdk/v2"
-import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared"
+import { OpenCode } from "@opencode-ai/client/promise"
+import { loadRunReferences, runProviders, waitForDefaultModel } from "@opencode-ai/cli/mini/catalog.shared"
 
 afterEach(() => {
   mock.restore()
@@ -8,20 +8,12 @@ afterEach(() => {
 
 describe("run catalog shared", () => {
   test("resolves the catalog-selected model for the footer", async () => {
-    const client = new OpencodeClient()
-    const selected = spyOn(client.v2.model, "default").mockImplementation(
+    const client = OpenCode.make({ baseUrl: "https://opencode.test" })
+    const selected = spyOn(client.model, "default").mockImplementation(
       () =>
         Promise.resolve({
-          data: {
-            location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-            data: {
-              id: "gpt-5",
-              providerID: "openai",
-            },
-          },
-          error: undefined,
-          request: new Request("https://opencode.test"),
-          response: new Response(),
+          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
+          data: { id: "gpt-5", providerID: "openai" },
         }) as never,
     )
 
@@ -29,17 +21,16 @@ describe("run catalog shared", () => {
       providerID: "openai",
       modelID: "gpt-5",
     })
-    expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
+    expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
   })
 
   test("loads visible project references from the current reference catalog", async () => {
-    const client = new OpencodeClient()
-    const list = spyOn(client.v2.reference, "list").mockImplementation(
+    const client = OpenCode.make({ baseUrl: "https://opencode.test" })
+    const list = spyOn(client.reference, "list").mockImplementation(
       () =>
         Promise.resolve({
-          data: {
-            location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-            data: [
+          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
+          data: [
               {
                 name: "effect",
                 path: "/repos/effect",
@@ -52,17 +43,13 @@ describe("run catalog shared", () => {
                 hidden: true,
                 source: { type: "local", path: "/repos/secret" },
               },
-            ],
-          },
-          error: undefined,
-          request: new Request("https://opencode.test"),
-          response: new Response(),
+          ],
         }) as never,
     )
 
     const references = await loadRunReferences(client, "/tmp")
 
-    expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
+    expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } })
     expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }])
   })
 

+ 2 - 2
packages/opencode/test/cli/run/entry.body.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
-import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
-import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
+import { entryBody, entryCanStream, entryDone } from "@opencode-ai/cli/mini/entry.body"
+import type { StreamCommit, ToolSnapshot } from "@opencode-ai/cli/mini/types"
 
 function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
   return input

+ 1 - 1
packages/opencode/test/cli/run/footer.menu.test.ts

@@ -1,6 +1,6 @@
 import { expect, test } from "bun:test"
 import { createRoot } from "solid-js"
-import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
+import { FOOTER_MENU_ROWS, createFooterMenuState } from "@opencode-ai/cli/mini/footer.menu"
 
 function mount(count: number, limit = FOOTER_MENU_ROWS) {
   let dispose!: () => void

+ 8 - 8
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -15,10 +15,10 @@ import {
   RunSkillSelectBody,
   RunSubagentSelectBody,
   RunVariantSelectBody,
-} from "@/cli/cmd/run/footer.command"
-import { RunFooterView } from "@/cli/cmd/run/footer.view"
-import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
-import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
+} from "@opencode-ai/cli/mini/footer.command"
+import { RunFooterView } from "@opencode-ai/cli/mini/footer.view"
+import { RunEntryContent } from "@opencode-ai/cli/mini/scrollback.writer"
+import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
 import type {
   FooterState,
   FooterSubagentState,
@@ -30,10 +30,10 @@ import type {
   RunProvider,
   RunTuiConfig,
   StreamCommit,
-} from "@/cli/cmd/run/types"
-import { RunQuestionBody } from "@/cli/cmd/run/footer.question"
-import { selectedCommand } from "@/cli/cmd/run/footer.prompt"
-import { RejectField } from "@/cli/cmd/run/footer.permission"
+} from "@opencode-ai/cli/mini/types"
+import { RunQuestionBody } from "@opencode-ai/cli/mini/footer.question"
+import { selectedCommand } from "@opencode-ai/cli/mini/footer.prompt"
+import { RejectField } from "@opencode-ai/cli/mini/footer.permission"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
 const tuiConfig = createTuiResolvedConfig()

+ 1 - 1
packages/opencode/test/cli/run/footer.width.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { footerWidthPolicy } from "@/cli/cmd/run/footer.width"
+import { footerWidthPolicy } from "@opencode-ai/cli/mini/footer.width"
 
 describe("run footer width", () => {
   test("preserves shared dialog and statusline breakpoints", () => {

+ 21 - 27
packages/opencode/test/cli/run/noninteractive.test.ts

@@ -1,16 +1,12 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
-import { runNonInteractivePrompt } from "@/cli/cmd/run/noninteractive"
+import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
+import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
 
+type V2Event = EventSubscribeOutput
 type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
 
 function ok<T>(data: T) {
-  return Promise.resolve({
-    data,
-    error: undefined,
-    request: new Request("https://opencode.test"),
-    response: new Response(),
-  })
+  return Promise.resolve(data)
 }
 
 function form(id: string, sessionID: string): FormInfo {
@@ -43,8 +39,8 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
 // 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 }) {
-  const sdk = new OpencodeClient()
-  const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }]
+  const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
+  const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
   let wake: (() => void) | undefined
   const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
     while (true) {
@@ -58,22 +54,20 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
       yield value
     }
   })()
-  spyOn(sdk.v2.event, "subscribe").mockImplementation(
-    () => Promise.resolve({ stream }) as ReturnType<typeof sdk.v2.event.subscribe>,
-  )
-  spyOn(sdk.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }) as never)
-  spyOn(sdk.v2.session.question, "list").mockImplementation(() => ok({ data: [] }) as never)
-  spyOn(sdk.v2.session.form, "list").mockImplementation(
+  spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
+  spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
+  spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
+  spyOn(sdk.form, "list").mockImplementation(
     (request) =>
-      ok({ data: input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? [] }) as never,
+      ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
   )
-  spyOn(sdk.v2.session.form, "cancel").mockImplementation(() => ok(undefined) as never)
-  spyOn(sdk.v2.session, "prompt").mockImplementation((request) => {
+  spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
+  spyOn(sdk.session, "prompt").mockImplementation((request) => {
     const messageID = request.id ?? "msg_prompt"
     values.push(...input.turn(messageID))
     wake?.()
     wake = undefined
-    return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 } }) as never
+    return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
   })
   await runNonInteractivePrompt({
     client: sdk,
@@ -102,9 +96,9 @@ describe("runNonInteractivePrompt", () => {
       // which must not leave the consume loop waiting forever.
       turn: () => [formCreated(form("frm_live", "global")), settled("interrupted")],
     })
-    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
-    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
-    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
+    expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
+    expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
+    expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
   })
 
   test("attach mode cancels only session-owned forms", async () => {
@@ -113,9 +107,9 @@ describe("runNonInteractivePrompt", () => {
       pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
       turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
     })
-    expect(sdk.v2.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
-    expect(sdk.v2.session.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
-    expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
-    expect(sdk.v2.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
+    expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
+    expect(sdk.form.list).not.toHaveBeenCalledWith({ sessionID: "global" })
+    expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
+    expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
   })
 })

+ 1 - 1
packages/opencode/test/cli/run/permission.shared.test.ts

@@ -8,7 +8,7 @@ import {
   permissionInfo,
   permissionReject,
   permissionRun,
-} from "@/cli/cmd/run/permission.shared"
+} from "@opencode-ai/cli/mini/permission.shared"
 
 function req(input: Partial<PermissionRequest> = {}): PermissionRequest {
   return {

+ 2 - 2
packages/opencode/test/cli/run/prompt.editor.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { realignEditorPromptParts, resolveEditorSlashValue } from "@/cli/cmd/run/prompt.editor"
-import type { RunPromptPart } from "@/cli/cmd/run/types"
+import { realignEditorPromptParts, resolveEditorSlashValue } from "@opencode-ai/cli/mini/prompt.editor"
+import type { RunPromptPart } from "@opencode-ai/cli/mini/types"
 
 describe("run prompt editor helpers", () => {
   test("strips the local /editor command from the initial editor text", () => {

+ 2 - 2
packages/opencode/test/cli/run/prompt.shared.test.ts

@@ -5,8 +5,8 @@ import {
   isNewCommand,
   movePromptHistory,
   pushPromptHistory,
-} from "@/cli/cmd/run/prompt.shared"
-import type { RunPrompt } from "@/cli/cmd/run/types"
+} from "@opencode-ai/cli/mini/prompt.shared"
+import type { RunPrompt } from "@opencode-ai/cli/mini/types"
 
 function prompt(text: string, parts: RunPrompt["parts"] = []): RunPrompt {
   return { text, parts }

+ 1 - 1
packages/opencode/test/cli/run/question.shared.test.ts

@@ -10,7 +10,7 @@ import {
   questionStoreCustom,
   questionSubmit,
   questionSync,
-} from "@/cli/cmd/run/question.shared"
+} from "@opencode-ai/cli/mini/question.shared"
 
 function req(input: Partial<QuestionRequest> = {}): QuestionRequest {
   return {

+ 2 - 2
packages/opencode/test/cli/run/run-process.test.ts

@@ -363,11 +363,11 @@ describe("opencode run (non-interactive subprocess)", () => {
   )
 
   cliIt.concurrent(
-    "applies a variant to the configured default model",
+    "applies a variant to the selected model",
     ({ llm, opencode }) =>
       Effect.gen(function* () {
         yield* llm.text("variant response")
-        const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], {
+        const result = yield* opencode.spawn(["run", "--model", "test/test-model", "--variant", "default", "use the model"], {
           config: { ...testProviderConfig(llm.url), model: "test/test-model" },
         })
 

+ 27 - 49
packages/opencode/test/cli/run/runtime.boot.test.ts

@@ -1,17 +1,11 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { OpenCode } from "@opencode-ai/client/promise"
 import type { Resolved } from "@opencode-ai/tui/config"
-import { TuiConfig } from "@/config/tui"
-import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot"
+import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
 import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
 
 function ok<T>(data: T) {
-  return Promise.resolve({
-    data,
-    error: undefined,
-    request: new Request("https://opencode.test"),
-    response: new Response(),
-  })
+  return Promise.resolve(data)
 }
 
 function provider(id: string, name: string) {
@@ -108,23 +102,21 @@ describe("run runtime boot", () => {
   })
 
   test("reads footer keybinds from resolved keybind config", async () => {
-    spyOn(TuiConfig, "get").mockResolvedValue(
-      config({
-        leader: "ctrl+g",
-        bindings: {
-          commandList: ["ctrl+p"],
-          variantCycle: ["ctrl+t", "alt+t"],
-          interrupt: ["ctrl+c"],
-          historyPrevious: ["k"],
-          historyNext: ["j"],
-          inputClear: ["ctrl+l"],
-          inputSubmit: ["ctrl+s"],
-          inputNewline: ["alt+return"],
-        },
-      }),
-    )
+    const input = config({
+      leader: "ctrl+g",
+      bindings: {
+        commandList: ["ctrl+p"],
+        variantCycle: ["ctrl+t", "alt+t"],
+        interrupt: ["ctrl+c"],
+        historyPrevious: ["k"],
+        historyNext: ["j"],
+        inputClear: ["ctrl+l"],
+        inputSubmit: ["ctrl+s"],
+        inputNewline: ["alt+return"],
+      },
+    })
 
-    const result = await resolveRunTuiConfig()
+    const result = await resolveRunTuiConfig(input)
 
     expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
     expect(result.leader_timeout).toBe(2000)
@@ -139,9 +131,7 @@ describe("run runtime boot", () => {
   })
 
   test("falls back to default tui keymap config when config load fails", async () => {
-    spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
-
-    const result = await resolveRunTuiConfig()
+    const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
 
     expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+x")
     expect(result.leader_timeout).toBe(2000)
@@ -157,31 +147,23 @@ describe("run runtime boot", () => {
   })
 
   test("preserves disabled leader from resolved tui config", async () => {
-    spyOn(TuiConfig, "get").mockResolvedValue(config({ leader: "none" }))
-
-    const result = await resolveRunTuiConfig()
+    const result = await resolveRunTuiConfig(config({ leader: "none" }))
 
     expect(result.keybinds.get("leader")).toEqual([])
   })
 
   test("reads diff style and falls back to auto", async () => {
-    spyOn(TuiConfig, "get").mockResolvedValue(config({ diff_style: "stacked" }))
-    await expect(resolveDiffStyle()).resolves.toBe("stacked")
+    await expect(resolveDiffStyle(config({ diff_style: "stacked" }))).resolves.toBe("stacked")
 
-    mock.restore()
-    spyOn(TuiConfig, "get").mockRejectedValue(new Error("boom"))
-    await expect(resolveDiffStyle()).resolves.toBe("auto")
+    await expect(resolveDiffStyle(Promise.reject(new Error("boom")))).resolves.toBe("auto")
   })
 
   test("loads v2 providers and models for model selector data", async () => {
-    const sdk = new OpencodeClient()
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
     const providers = [provider("openai", "OpenAI")]
     const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
-    // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
+    const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
+    spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
 
     await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
       providers: [
@@ -230,19 +212,15 @@ describe("run runtime boot", () => {
           directory: "/workspace",
         },
       },
-      { throwOnError: true },
     )
   })
 
   test("loads context limits across v2 providers", async () => {
-    const sdk = new OpencodeClient()
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
     const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")]
     const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)]
-    // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch.
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers }))
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models }))
+    spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
+    spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
 
     await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({
       providers: [

+ 2 - 2
packages/opencode/test/cli/run/runtime.queue.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { runPromptQueue } from "@/cli/cmd/run/runtime.queue"
-import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@/cli/cmd/run/types"
+import { runPromptQueue } from "@opencode-ai/cli/mini/runtime.queue"
+import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "@opencode-ai/cli/mini/types"
 
 function footer() {
   const prompts = new Set<(input: RunPrompt) => void>()

+ 1 - 1
packages/opencode/test/cli/run/runtime.stdin.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { Readable } from "node:stream"
-import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@/cli/cmd/run/runtime.stdin"
+import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "@opencode-ai/cli/mini/runtime.stdin"
 
 function stream(isTTY: boolean) {
   return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream

+ 281 - 106
packages/opencode/test/cli/run/runtime.test.ts

@@ -1,7 +1,7 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { OpencodeClient } from "@opencode-ai/sdk/v2"
-import { runInteractiveMode } from "@/cli/cmd/run/runtime"
-import type { FooterApi, FooterEvent, RunProvider } from "@/cli/cmd/run/types"
+import { OpenCode } from "@opencode-ai/client/promise"
+import { runInteractiveDeferredMode, runInteractiveMode } from "@opencode-ai/cli/mini/runtime"
+import type { FooterApi, FooterEvent, RunProvider } from "@opencode-ai/cli/mini/types"
 
 const provider: RunProvider = {
   id: "openai",
@@ -45,12 +45,7 @@ function defer<T>() {
 }
 
 function ok<T>(data: T) {
-  return Promise.resolve({
-    data,
-    error: undefined,
-    request: new Request("https://opencode.test"),
-    response: new Response(),
-  })
+  return Promise.resolve(data)
 }
 
 function footer(events: FooterEvent[] = []): FooterApi {
@@ -110,16 +105,173 @@ afterEach(() => {
 })
 
 describe("run interactive runtime", () => {
+  test("resolves the deferred session only after first paint", async () => {
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
+    const lifecycleStarted = defer<void>()
+    const painted = defer<void>()
+    const api = footer()
+    let resolved = 0
+    api.idle = () => painted.promise
+    spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+
+    const task = runInteractiveDeferredMode(
+      {
+        sdk,
+        directory: "/tmp",
+        resolveAgent: async () => "build",
+        session: async () => {
+          resolved++
+          api.close()
+          return { id: "ses-deferred", title: "Deferred" }
+        },
+        agent: "build",
+        model: { providerID: "openai", modelID: "gpt-5" },
+        variant: undefined,
+        files: [],
+        thinking: false,
+        backgroundSubagents: false,
+      },
+      {
+        createRuntimeLifecycle: async () => {
+          lifecycleStarted.resolve()
+          return {
+            footer: api,
+            onResize: () => () => {},
+            refreshTheme: () => {},
+            resetForReplay: () => Promise.resolve(),
+            close: () => Promise.resolve(),
+          }
+        },
+      },
+    )
+
+    await lifecycleStarted.promise
+    expect(resolved).toBe(0)
+    painted.resolve()
+    await task
+    expect(resolved).toBe(1)
+  })
+
+  test("restores deferred session history and model after first paint", async () => {
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
+    const lifecycleStarted = defer<void>()
+    const painted = defer<void>()
+    const events: FooterEvent[] = []
+    const api = footer(events)
+    api.idle = () => painted.promise
+    const event = api.event
+    api.event = (value) => {
+      event(value)
+      if (value.type === "model") api.close()
+    }
+    spyOn(sdk.session, "get").mockImplementation(
+      () =>
+        ok({
+          id: "ses-resume",
+          projectID: "pro-1",
+          title: "Resume",
+          cost: 0,
+          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+          time: { created: 1, updated: 1 },
+          location: { directory: "/tmp" },
+          model: { providerID: "openai", id: "gpt-5", variant: "high" },
+        }) as never,
+    )
+    spyOn(sdk.message, "list").mockImplementation(
+      () =>
+        ok({
+          data: [{ id: "msg-user", type: "user", text: "previous prompt", time: { created: 1 } }],
+          cursor: {},
+        }) as never,
+    )
+    spyOn(sdk.provider, "list").mockImplementation(
+      () =>
+        ok({
+          location: { directory: "/tmp" },
+          data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
+        }) as never,
+    )
+    spyOn(sdk.model, "list").mockImplementation(
+      () =>
+        ok({
+          location: { directory: "/tmp" },
+          data: [
+            {
+              id: "gpt-5",
+              providerID: "openai",
+              name: "Little Frank",
+              capabilities: { tools: true, input: ["text"], output: ["text"] },
+              request: { headers: {}, body: {} },
+              variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
+              time: { released: 1 },
+              cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
+              status: "active",
+              enabled: true,
+              limit: { context: 128000, output: 8192 },
+            },
+          ],
+        }) as never,
+    )
+    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+
+    const task = runInteractiveDeferredMode(
+      {
+        sdk,
+        directory: "/tmp",
+        resolveAgent: async () => "build",
+        session: async () => ({ id: "ses-resume", title: "Resume", resume: true }),
+        agent: "build",
+        model: undefined,
+        variant: undefined,
+        files: [],
+        thinking: false,
+        backgroundSubagents: false,
+      },
+      {
+        createRuntimeLifecycle: async () => {
+          lifecycleStarted.resolve()
+          return {
+            footer: api,
+            onResize: () => () => {},
+            refreshTheme: () => {},
+            resetForReplay: () => Promise.resolve(),
+            close: () => Promise.resolve(),
+          }
+        },
+      },
+    )
+
+    await lifecycleStarted.promise
+    expect(sdk.session.get).not.toHaveBeenCalled()
+    painted.resolve()
+    await task
+
+    expect(events).toContainEqual({
+      type: "history",
+      history: [{ text: "previous prompt", parts: [] }],
+    })
+    expect(events).toContainEqual({
+      type: "model",
+      model: "Little Frank · OpenAI · high",
+      selection: { providerID: "openai", modelID: "gpt-5" },
+    })
+  })
+
   test("waits for provider metadata before eager replay transport bootstrap", async () => {
     const providersStarted = defer<void>()
     const providers = defer<void>()
     const lifecycleModels: unknown[] = []
 
-    const sdk = new OpencodeClient()
-    const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
-    const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused"))
-    const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused"))
-    spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
+    spyOn(sdk.provider, "list").mockImplementation(async () => {
       providersStarted.resolve()
       await providers.promise
       return ok({
@@ -142,55 +294,56 @@ describe("run interactive runtime", () => {
         ],
       }) as never
     })
-    spyOn(sdk.v2.model, "list").mockImplementation(() =>
-      ok({
-        location: {
-          directory: "/tmp",
-        },
-        data: [
-          {
-            id: "gpt-5",
-            providerID: "openai",
-            name: "Little Frank",
-            api: {
-              id: "openai",
-              type: "native",
-              settings: {},
-            },
-            capabilities: {
-              tools: true,
-              input: ["text"],
-              output: ["text"],
-            },
-            request: {
-              headers: {},
-              body: {},
-            },
-            variants: [],
-            time: {
-              released: 1,
-            },
-            cost: [
-              {
-                input: 0,
-                output: 0,
-                cache: {
-                  read: 0,
-                  write: 0,
+    spyOn(sdk.model, "list").mockImplementation(
+      () =>
+        ok({
+          location: {
+            directory: "/tmp",
+          },
+          data: [
+            {
+              id: "gpt-5",
+              providerID: "openai",
+              name: "Little Frank",
+              api: {
+                id: "openai",
+                type: "native",
+                settings: {},
+              },
+              capabilities: {
+                tools: true,
+                input: ["text"],
+                output: ["text"],
+              },
+              request: {
+                headers: {},
+                body: {},
+              },
+              variants: [],
+              time: {
+                released: 1,
+              },
+              cost: [
+                {
+                  input: 0,
+                  output: 0,
+                  cache: {
+                    read: 0,
+                    write: 0,
+                  },
                 },
+              ],
+              status: "active",
+              enabled: true,
+              limit: {
+                context: 128000,
+                output: 8192,
               },
-            ],
-            status: "active",
-            enabled: true,
-            limit: {
-              context: 128000,
-              output: 8192,
             },
-          },
-        ],
-      }) as never,
+          ],
+        }) as never,
     )
-    spyOn(sdk.v2.session, "messages").mockImplementation(() =>
+    spyOn(sdk.message, "list").mockImplementation(() =>
       ok({
         data: [
           {
@@ -205,9 +358,9 @@ describe("run interactive runtime", () => {
         cursor: {},
       }),
     )
-    spyOn(sdk.v2.session, "get").mockImplementation(() =>
-      ok({
-        data: {
+    spyOn(sdk.session, "get").mockImplementation(
+      () =>
+        ok({
           id: "ses-1",
           projectID: "pro-1",
           title: "Session",
@@ -232,13 +385,12 @@ describe("run interactive runtime", () => {
             providerID: "openai",
             id: "gpt-5",
           },
-        },
-      }),
+        }) as never,
     )
-    spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
-    spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
 
     const task = runInteractiveMode(
       {
@@ -296,13 +448,10 @@ describe("run interactive runtime", () => {
 
     expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
     expect(transportProviders).toEqual([[provider]])
-    expect(legacyProviders).not.toHaveBeenCalled()
-    expect(legacyAgents).not.toHaveBeenCalled()
-    expect(legacyCommands).not.toHaveBeenCalled()
   })
 
   test("defers catalog-selected model resolution until after first paint", async () => {
-    const sdk = new OpencodeClient()
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
     const defaultStarted = defer<void>()
     const releaseDefault = defer<void>()
     const lifecycleStarted = defer<void>()
@@ -320,7 +469,7 @@ describe("run interactive runtime", () => {
       api.close()
     }
 
-    spyOn(sdk.v2.model, "default").mockImplementation(async () => {
+    spyOn(sdk.model, "default").mockImplementation(async () => {
       defaultRequested = true
       defaultStarted.resolve()
       await releaseDefault.promise
@@ -329,24 +478,12 @@ describe("run interactive runtime", () => {
         data: { id: "gpt-5", providerID: "openai" },
       }) as never
     })
-    spyOn(sdk.v2.provider, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
-    spyOn(sdk.v2.model, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
-    spyOn(sdk.v2.agent, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
-    spyOn(sdk.v2.reference, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
-    spyOn(sdk.v2.command, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
-    spyOn(sdk.v2.skill, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
-    )
+    spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
+    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
 
     const task = runInteractiveMode(
       {
@@ -402,12 +539,12 @@ describe("run interactive runtime", () => {
   })
 
   test("does not start deferred work after the footer closes", async () => {
-    const sdk = new OpencodeClient()
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
     const lifecycleStarted = defer<void>()
     const painted = defer<void>()
     const api = footer()
     api.idle = () => painted.promise
-    const defaultModel = spyOn(sdk.v2.model, "default")
+    const defaultModel = spyOn(sdk.model, "default")
 
     const task = runInteractiveMode(
       {
@@ -444,8 +581,50 @@ describe("run interactive runtime", () => {
     expect(defaultModel).not.toHaveBeenCalled()
   })
 
+  test("searches files through the V2 file API", async () => {
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
+    const api = footer()
+    const find = spyOn(sdk.file, "find").mockImplementation(
+      () =>
+        ok({
+          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
+          data: [{ path: "src/index.ts", type: "file" }],
+        }) as never,
+    )
+
+    await runInteractiveMode(
+      {
+        sdk,
+        directory: "/tmp",
+        sessionID: "ses-files",
+        resume: false,
+        agent: "build",
+        model: undefined,
+        variant: undefined,
+        files: [],
+        thinking: false,
+        backgroundSubagents: false,
+      },
+      {
+        createRuntimeLifecycle: async (input) => {
+          await expect(input.findFiles("index")).resolves.toEqual(["src/index.ts"])
+          api.close()
+          return {
+            footer: api,
+            onResize: () => () => {},
+            refreshTheme: () => {},
+            resetForReplay: () => Promise.resolve(),
+            close: () => Promise.resolve(),
+          }
+        },
+      },
+    )
+
+    expect(find).toHaveBeenCalledWith({ query: "index", type: "file", location: { directory: "/tmp" } })
+  })
+
   test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
-    const sdk = new OpencodeClient()
+    const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
     const refreshGate = defer<void>()
     let providerCalls = 0
     let modelCalls = 0
@@ -453,7 +632,7 @@ describe("run interactive runtime", () => {
     let referenceCalls = 0
     const events: FooterEvent[] = []
     const api = footer(events)
-    spyOn(sdk.v2.provider, "list").mockImplementation(async () => {
+    spyOn(sdk.provider, "list").mockImplementation(async () => {
       providerCalls++
       if (providerCalls === 2) {
         await refreshGate.promise
@@ -471,7 +650,7 @@ describe("run interactive runtime", () => {
         ],
       }) as never
     })
-    spyOn(sdk.v2.model, "list").mockImplementation(() => {
+    spyOn(sdk.model, "list").mockImplementation(() => {
       modelCalls++
       return ok({
         location: { directory: "/tmp" },
@@ -484,9 +663,7 @@ describe("run interactive runtime", () => {
             capabilities: { tools: true, input: ["text"], output: ["text"] },
             request: { headers: {}, body: {} },
             variants:
-              modelCalls >= 4
-                ? []
-                : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
+              modelCalls >= 4 ? [] : [{ id: modelCalls >= 3 ? "high" : "low", settings: {}, headers: {}, body: {} }],
             time: { released: 1 },
             cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
             status: "active",
@@ -496,7 +673,7 @@ describe("run interactive runtime", () => {
         ],
       }) as never
     })
-    spyOn(sdk.v2.agent, "list").mockImplementation(async () => {
+    spyOn(sdk.agent, "list").mockImplementation(async () => {
       agentCalls++
       if (agentCalls === 2) throw new Error("agent refresh failed")
       return ok({
@@ -504,7 +681,7 @@ describe("run interactive runtime", () => {
         data: [{ id: "build", description: agentCalls >= 3 ? "Refreshed agent" : "Agent", mode: "primary" }],
       }) as never
     })
-    spyOn(sdk.v2.reference, "list").mockImplementation(() => {
+    spyOn(sdk.reference, "list").mockImplementation(() => {
       referenceCalls++
       return ok({
         location: { directory: "/tmp" },
@@ -513,12 +690,10 @@ describe("run interactive runtime", () => {
         ],
       }) as never
     })
-    spyOn(sdk.v2.command, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
-    )
-    spyOn(sdk.v2.skill, "list").mockImplementation(() =>
-      ok({ location: { directory: "/tmp" }, data: [] }) as never,
+    spyOn(sdk.command, "list").mockImplementation(
+      () => ok({ location: { directory: "/tmp" }, data: [{ name: "check", description: "Check" }] }) as never,
     )
+    spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
     let finalProviders: RunProvider[] = []
     let finalLimits: Record<string, number> = {}
     let retainedProviders: RunProvider[] = []

+ 3 - 3
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -2,9 +2,9 @@ import { afterEach, expect, test } from "bun:test"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
 import { RGBA, SyntaxStyle } from "@opentui/core"
 import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
-import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
-import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
-import type { StreamCommit } from "@/cli/cmd/run/types"
+import { RunScrollbackStream } from "@opencode-ai/cli/mini/scrollback.surface"
+import { RUN_THEME_FALLBACK, type RunTheme } from "@opencode-ai/cli/mini/theme"
+import type { StreamCommit } from "@opencode-ai/cli/mini/types"
 
 type ClaimedCommit = {
   snapshot: {

+ 2 - 2
packages/opencode/test/cli/run/session-data.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import type { Event } from "@opencode-ai/sdk/v2"
-import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data"
-import type { StreamCommit } from "@/cli/cmd/run/types"
+import { createSessionData, reduceSessionData } from "@opencode-ai/cli/mini/session-data"
+import type { StreamCommit } from "@opencode-ai/cli/mini/types"
 
 function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
   return reduceSessionData({

+ 16 - 29
packages/opencode/test/cli/run/session.shared.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { OpenCode } from "@opencode-ai/client/promise"
 import {
   createSession,
   resolveCurrentSession,
@@ -7,7 +7,7 @@ import {
   sessionVariant,
   type RunSession,
   type SessionMessages,
-} from "@/cli/cmd/run/session.shared"
+} from "@opencode-ai/cli/mini/session.shared"
 
 type Message = SessionMessages[number]
 type Part = Message["parts"][number]
@@ -252,11 +252,10 @@ describe("run session shared", () => {
   })
 
   test("restores current prompt history from stored text and file references", async () => {
-    const client = new OpencodeClient()
-    spyOn(client.v2.session, "messages").mockImplementation(() =>
+    const client = OpenCode.make({ baseUrl: "https://opencode.test" })
+    spyOn(client.message, "list").mockImplementation(() =>
       Promise.resolve({
-        data: {
-          data: [
+        data: [
             {
               id: "msg_prompt",
               type: "user",
@@ -272,32 +271,20 @@ describe("run session shared", () => {
               agents: [],
               time: { created: 1 },
             },
-          ],
-          cursor: {},
-        },
-        error: undefined,
-        request: new Request("https://opencode.test"),
-        response: new Response(),
+        ],
+        cursor: {},
       }),
     )
-    spyOn(client.v2.session, "get").mockImplementation(() =>
+    spyOn(client.session, "get").mockImplementation(() =>
       Promise.resolve({
-        data: {
-          data: {
-            id: "ses_1",
-            title: "Session",
-            version: "dev",
-            projectID: "proj_1",
-            location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-            time: { created: 1, updated: 1 },
-            cost: 0,
-            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-            model: { providerID: "openai", id: "gpt-5", variant: "high" },
-          },
-        },
-        error: undefined,
-        request: new Request("https://opencode.test"),
-        response: new Response(),
+        id: "ses_1",
+        title: "Session",
+        projectID: "proj_1",
+        location: { directory: "/tmp" },
+        time: { created: 1, updated: 1 },
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        model: { providerID: "openai", id: "gpt-5", variant: "high" },
       }),
     )
 

+ 99 - 128
packages/opencode/test/cli/run/stream-v2.transport.test.ts

@@ -2,12 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "node:url"
-import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2"
-import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport"
-import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
+import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
+import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
+import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
 import { tmpdir } from "../../fixture/fixture"
 
-type RunV2Event = V2Event
+type RunV2Event = EventSubscribeOutput
 
 function feed() {
   const values: RunV2Event[] = []
@@ -41,16 +41,11 @@ function feed() {
 }
 
 function ok<T>(data: T) {
-  return Promise.resolve({
-    data,
-    error: undefined,
-    request: new Request("https://opencode.test"),
-    response: new Response(),
-  })
+  return Promise.resolve(data)
 }
 
 function connected(id = "evt_connected") {
-  return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event
+  return { id, type: "server.connected", data: {} } satisfies RunV2Event
 }
 
 function durable(sessionID: string, seq = 0, version = 1) {
@@ -85,9 +80,7 @@ function footer() {
   return { api, commits, events }
 }
 
-type SessionMessages = NonNullable<
-  Awaited<ReturnType<OpencodeClient["v2"]["session"]["messages"]>>["data"]
->["data"][number][]
+type SessionMessages = MessageListOutput["data"]
 
 function sdk(input: {
   streams: ReturnType<typeof feed>[]
@@ -95,15 +88,10 @@ function sdk(input: {
   messages?: Record<string, SessionMessages>
   sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }>
 }) {
-  const client = new OpencodeClient()
+  const client = OpenCode.make({ baseUrl: "https://opencode.test" })
   let subscription = 0
-  spyOn(client.v2.event, "subscribe").mockImplementation(
-    () =>
-      Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType<
-        typeof client.v2.event.subscribe
-      >,
-  )
-  spyOn(client.v2.session, "messages").mockImplementation((request) =>
+  spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
+  spyOn(client.message, "list").mockImplementation((request) =>
     ok({
       data: input.messages?.[request.sessionID] ?? [
         {
@@ -118,14 +106,14 @@ function sdk(input: {
       cursor: {},
     }),
   )
-  spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
-  spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
-  spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
-  spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
-  spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
+  spyOn(client.permission, "list").mockImplementation(() => ok([]))
+  spyOn(client.question, "list").mockImplementation(() => ok([]))
+  spyOn(client.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
+  spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
+  spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
   // The generated methods have conditional return types for throwOnError; the
   // minimal shapes below are enough for family discovery and model fallback.
-  spyOn(client.v2.session, "list").mockImplementation((request) => {
+  spyOn(client.session, "list").mockImplementation((request) => {
     const parentID = request?.parentID
     return ok({
       location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -139,7 +127,7 @@ function sdk(input: {
         ) ?? [],
     }) as never
   })
-  spyOn(client.v2.model, "default").mockImplementation(
+  spyOn(client.model, "default").mockImplementation(
     () =>
       ok({
         location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
@@ -170,9 +158,7 @@ describe("V2 mini transport", () => {
     expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"])
 
     let admitted = false
-    // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -185,7 +171,7 @@ describe("V2 mini transport", () => {
           delivery: "steer" as const,
           timeCreated: 2,
         },
-      })
+      }) as never
     })
 
     const turn = transport.runPromptTurn({
@@ -250,10 +236,8 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
-    // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
-    // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((input) => {
+    let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
+    spyOn(client.session, "prompt").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -282,7 +266,7 @@ describe("V2 mini transport", () => {
           delivery: "steer" as const,
           timeCreated: 2,
         },
-      })
+      }) as never
     })
 
     await transport.runPromptTurn({
@@ -344,10 +328,10 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
+    let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((input) => {
+    spyOn(client.session, "prompt").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -441,10 +425,10 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["prompt"]>[0] | undefined
+    let request: Parameters<OpenCodeClient["session"]["prompt"]>[0] | undefined
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((input) => {
+    spyOn(client.session, "prompt").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -561,7 +545,7 @@ describe("V2 mini transport", () => {
       },
     })
     let projected = false
-    spyOn(client.v2.session, "messages").mockImplementation(() =>
+    spyOn(client.message, "list").mockImplementation(() =>
       ok({
         data: projected
           ? [
@@ -589,7 +573,7 @@ describe("V2 mini transport", () => {
     let admitted = false
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -632,7 +616,7 @@ describe("V2 mini transport", () => {
         return active
       },
     })
-    spyOn(client.v2.session, "messages").mockImplementation(() =>
+    spyOn(client.message, "list").mockImplementation(() =>
       ok({
         data: projected
           ? [
@@ -661,7 +645,7 @@ describe("V2 mini transport", () => {
     let admitted = false
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -701,7 +685,7 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    spyOn(client.v2.session, "messages").mockImplementation(() =>
+    spyOn(client.message, "list").mockImplementation(() =>
       ok({
         data: [
           {
@@ -745,7 +729,7 @@ describe("V2 mini transport", () => {
     const events = feed()
     events.push(connected())
     const client = sdk({ streams: [events] })
-    spyOn(client.v2.session, "messages").mockImplementation(() =>
+    spyOn(client.message, "list").mockImplementation(() =>
       ok({
         data: [
           {
@@ -842,7 +826,7 @@ describe("V2 mini transport", () => {
     let admitted = false
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -850,7 +834,7 @@ describe("V2 mini transport", () => {
         data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
       })
     })
-    const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
+    const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
 
     const turn = transport.runPromptTurn({
       agent: undefined,
@@ -886,21 +870,19 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    // The generated method has conditional return types for throwOnError; the test only needs the nested model field.
-    // @ts-expect-error minimal session shape is enough for this lookup
-    spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } }))
-    spyOn(client.v2.model, "default").mockImplementation(
+    spyOn(client.session, "get").mockImplementation(() => ok({ model: undefined }) as never)
+    spyOn(client.model, "default").mockImplementation(
       () =>
         ok({
           location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
           data: { id: "gpt-5", providerID: "openai" },
         }) as never,
     )
-    const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
+    const switched = spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
     let admitted = false
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -938,7 +920,7 @@ describe("V2 mini transport", () => {
 
     expect(switched).toHaveBeenCalledWith(
       { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
-      expect.objectContaining({ throwOnError: true }),
+      { signal: undefined },
     )
     await transport.close()
   })
@@ -958,7 +940,7 @@ describe("V2 mini transport", () => {
     let admitted = false
     // The generated method has conditional return types for throwOnError; this mock represents the successful branch.
     // @ts-expect-error successful SDK response is valid for both modes at runtime
-    spyOn(client.v2.session, "prompt").mockImplementation((request) => {
+    spyOn(client.session, "prompt").mockImplementation((request) => {
       const messageID = request.id ?? "msg_prompt"
       const prompt = request.prompt ?? { text: "" }
       admitted = true
@@ -966,7 +948,7 @@ describe("V2 mini transport", () => {
         data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 },
       })
     })
-    const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
+    const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
     const controller = new AbortController()
     const turn = transport.runPromptTurn({
       agent: undefined,
@@ -1014,8 +996,8 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
-    spyOn(client.v2.session, "shell").mockImplementation((input) => {
+    let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
+    spyOn(client.session, "shell").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -1094,7 +1076,7 @@ describe("V2 mini transport", () => {
     })
     let started = false
     let aborted = false
-    spyOn(client.v2.session, "shell").mockImplementation(
+    spyOn(client.session, "shell").mockImplementation(
       (_input, options) =>
         new Promise((_, reject) => {
           started = true
@@ -1104,7 +1086,7 @@ describe("V2 mini transport", () => {
           })
         }) as never,
     )
-    const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined))
+    const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
 
     const turn = transport.runPromptTurn({
       agent: undefined,
@@ -1135,9 +1117,9 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["shell"]>[0] | undefined
+    let request: Parameters<OpenCodeClient["session"]["shell"]>[0] | undefined
     let complete!: () => void
-    spyOn(client.v2.session, "shell").mockImplementation((input) => {
+    spyOn(client.session, "shell").mockImplementation((input) => {
       request = input
       return new Promise<void>((resolve) => {
         complete = resolve
@@ -1414,8 +1396,8 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["command"]>[0] | undefined
-    spyOn(client.v2.session, "command").mockImplementation((input) => {
+    let request: Parameters<OpenCodeClient["session"]["command"]>[0] | undefined
+    spyOn(client.session, "command").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -1436,14 +1418,12 @@ describe("V2 mini transport", () => {
         })
       })
       return ok({
-        data: {
-          admittedSeq: 1,
-          id: input.id ?? "msg_cmd",
-          sessionID: "ses_1",
-          prompt: { text: "evaluated template" },
-          delivery: "steer" as const,
-          timeCreated: 2,
-        },
+        admittedSeq: 1,
+        id: input.id ?? "msg_cmd",
+        sessionID: "ses_1",
+        prompt: { text: "evaluated template" },
+        delivery: "steer" as const,
+        timeCreated: 2,
       })
     })
 
@@ -1471,8 +1451,8 @@ describe("V2 mini transport", () => {
       delivery: "steer",
     })
     // Selection rides the command payload; no separate client-side switch.
-    expect(client.v2.session.switchAgent).not.toHaveBeenCalled()
-    expect(client.v2.session.switchModel).not.toHaveBeenCalled()
+    expect(client.session.switchAgent).not.toHaveBeenCalled()
+    expect(client.session.switchModel).not.toHaveBeenCalled()
     await transport.close()
   })
 
@@ -1488,10 +1468,10 @@ describe("V2 mini transport", () => {
       limits: () => ({}),
       footer: ui.api,
     })
-    let request: Parameters<OpencodeClient["v2"]["session"]["skill"]>[0] | undefined
-    const command = spyOn(client.v2.session, "command")
-    const prompt = spyOn(client.v2.session, "prompt")
-    spyOn(client.v2.session, "skill").mockImplementation((input) => {
+    let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
+    const command = spyOn(client.session, "command")
+    const prompt = spyOn(client.session, "prompt")
+    spyOn(client.session, "skill").mockImplementation((input) => {
       request = input
       queueMicrotask(() => {
         events.push({
@@ -1551,7 +1531,7 @@ describe("V2 mini transport", () => {
       footer: ui.api,
     })
     let sent = false
-    spyOn(client.v2.session, "skill").mockImplementation(() => {
+    spyOn(client.session, "skill").mockImplementation(() => {
       sent = true
       return ok(undefined) as never
     })
@@ -1721,20 +1701,18 @@ describe("V2 mini transport", () => {
         ],
       },
     })
-    spyOn(client.v2.session, "get").mockImplementation(() =>
+    spyOn(client.session, "get").mockImplementation(() =>
       ok({
-        data: {
-          id: "ses_child",
-          parentID: "ses_1",
-          projectID: "proj_1",
-          agent: "explore",
-          cost: 0,
-          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-          time: { created: 1, updated: 1 },
-          title: "Find files",
-          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-        },
-      }),
+        id: "ses_child",
+        parentID: "ses_1",
+        projectID: "proj_1",
+        agent: "explore",
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        time: { created: 1, updated: 1 },
+        title: "Find files",
+        location: { directory: "/tmp" },
+      }) as never,
     )
     const ui = footer()
     const transport = await createSessionTransport({
@@ -1857,7 +1835,7 @@ describe("V2 mini transport", () => {
     const hydration = new Promise<void>((resolve) => {
       releaseHydration = resolve
     })
-    spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
+    spyOn(client.message, "list").mockImplementation(async (request) => {
       if (request.sessionID === "ses_child") {
         childHydrating = true
         await hydration
@@ -1927,7 +1905,7 @@ describe("V2 mini transport", () => {
     const retry = new Promise<void>((resolve) => {
       releaseRetry = resolve
     })
-    spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
+    spyOn(client.message, "list").mockImplementation(async (request) => {
       if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
       childRequests++
       if (childRequests === 1) {
@@ -2006,7 +1984,7 @@ describe("V2 mini transport", () => {
     const hydration = new Promise<void>((resolve) => {
       releaseHydration = resolve
     })
-    spyOn(client.v2.session, "messages").mockImplementation(async (request) => {
+    spyOn(client.message, "list").mockImplementation(async (request) => {
       if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
       childHydrating = true
       await hydration
@@ -2119,21 +2097,19 @@ describe("V2 mini transport", () => {
     const gate = new Promise<void>((resolve) => {
       resolveGet = resolve
     })
-    spyOn(client.v2.session, "get").mockImplementation(async () => {
+    spyOn(client.session, "get").mockImplementation(async () => {
       await gate
       return ok({
-        data: {
-          id: "ses_child",
-          parentID: "ses_1",
-          projectID: "proj_1",
-          agent: "explore",
-          cost: 0,
-          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-          time: { created: 1, updated: 1 },
-          title: "Find files",
-          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-        },
-      })
+        id: "ses_child",
+        parentID: "ses_1",
+        projectID: "proj_1",
+        agent: "explore",
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        time: { created: 1, updated: 1 },
+        title: "Find files",
+        location: { directory: "/tmp" },
+      }) as never
     })
     const ui = footer()
     const transport = await createSessionTransport({
@@ -2178,21 +2154,19 @@ describe("V2 mini transport", () => {
     const gate = new Promise<void>((resolve) => {
       resolveGet = resolve
     })
-    spyOn(client.v2.session, "get").mockImplementation(async () => {
+    spyOn(client.session, "get").mockImplementation(async () => {
       await gate
       return ok({
-        data: {
-          id: "ses_child",
-          parentID: "ses_1",
-          projectID: "proj_1",
-          agent: "explore",
-          cost: 0,
-          tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-          time: { created: 1, updated: 1 },
-          title: "Find files",
-          location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
-        },
-      })
+        id: "ses_child",
+        parentID: "ses_1",
+        projectID: "proj_1",
+        agent: "explore",
+        cost: 0,
+        tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+        time: { created: 1, updated: 1 },
+        title: "Find files",
+        location: { directory: "/tmp" },
+      }) as never
     })
     const ui = footer()
     const transport = await createSessionTransport({
@@ -2286,10 +2260,7 @@ describe("V2 mini transport", () => {
       footer: ui.api,
     })
     const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
-    expect(client.v2.session.list).toHaveBeenCalledWith(
-      { parentID: "ses_1", limit: 100, order: "desc" },
-      { throwOnError: true },
-    )
+    expect(client.session.list).toHaveBeenCalledWith({ parentID: "ses_1", limit: 100, order: "desc" })
     expect(states.at(-1)?.tabs).toMatchObject([
       {
         sessionID: "ses_child_old",

+ 2 - 2
packages/opencode/test/cli/run/stream.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { writeSessionOutput } from "@/cli/cmd/run/stream"
-import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types"
+import { writeSessionOutput } from "@opencode-ai/cli/mini/stream"
+import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
 
 function footer() {
   const events: FooterEvent[] = []

+ 1 - 1
packages/opencode/test/cli/run/theme.test.ts

@@ -1,6 +1,6 @@
 import { expect, test } from "bun:test"
 import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
-import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@/cli/cmd/run/theme"
+import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "@opencode-ai/cli/mini/theme"
 
 const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
 

+ 3 - 3
packages/opencode/test/cli/run/variant.shared.test.ts

@@ -10,9 +10,9 @@ import {
   formatModelLabel,
   pickVariant,
   resolveVariant,
-} from "@/cli/cmd/run/variant.shared"
-import type { SessionMessages } from "@/cli/cmd/run/session.shared"
-import type { RunProvider } from "@/cli/cmd/run/types"
+} from "@opencode-ai/cli/mini/variant.shared"
+import type { SessionMessages } from "@opencode-ai/cli/mini/session.shared"
+import type { RunProvider } from "@opencode-ai/cli/mini/types"
 import { testEffect } from "../../lib/effect"
 
 const model = {

+ 1 - 32
packages/opencode/test/cli/tui/thread.test.ts

@@ -4,7 +4,6 @@ import fs from "fs/promises"
 import path from "path"
 import yargs from "yargs"
 import { tmpdir } from "../../fixture/fixture"
-import { MiniLocalCommand } from "../../../src/cli/cmd/mini"
 import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
 import { cliIt } from "../../lib/cli-process"
 
@@ -38,7 +37,7 @@ describe("tui thread", () => {
     await check(".")
   })
 
-  test("resolves a relative mini project from PWD when cwd differs", async () => {
+  test("resolves a relative project from PWD when cwd differs", async () => {
     await using pwd = await tmpdir({ git: true })
     await using cwd = await tmpdir({ git: true })
 
@@ -46,18 +45,6 @@ describe("tui thread", () => {
     expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
   })
 
-  test("parses supported mini --no-replay forms", async () => {
-    for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) {
-      const args = await yargs([])
-        .command({ ...MiniLocalCommand, handler: () => {} })
-        .exitProcess(false)
-        .parse([option, "--replay-limit", "10"])
-
-      expect(args.replay === false || args.noReplay === true).toBe(true)
-      expect(args.replayLimit).toBe(10)
-    }
-  })
-
   test("preserves boolean negation for existing options", async () => {
     const args = await yargs([])
       .command({ ...TuiThreadCommand, handler: () => {} })
@@ -85,24 +72,6 @@ describe("tui thread", () => {
     }),
   )
 
-  cliIt.live("routes local sessions through mini", ({ opencode }) =>
-    Effect.gen(function* () {
-      const result = yield* opencode.spawn(["mini"])
-
-      opencode.expectExit(result, 1)
-      expect(result.stderr).toContain("opencode mini requires a TTY stdout")
-    }),
-  )
-
-  cliIt.live("routes attached sessions through mini attach", ({ opencode }) =>
-    Effect.gen(function* () {
-      const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"])
-
-      opencode.expectExit(result, 1)
-      expect(result.stderr).toContain("opencode mini requires a TTY stdout")
-    }),
-  )
-
   cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
     Effect.gen(function* () {
       const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])

+ 5 - 1
packages/protocol/src/groups/health.ts

@@ -4,7 +4,11 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 export const HealthGroup = HttpApiGroup.make("server.health")
   .add(
     HttpApiEndpoint.get("health.get", "/api/health", {
-      success: Schema.Struct({ healthy: Schema.Literal(true) }),
+      success: Schema.Struct({
+        healthy: Schema.Literal(true),
+        version: Schema.String,
+        pid: Schema.Int.check(Schema.isGreaterThan(0)),
+      }),
     }).annotateMerge(
       OpenApi.annotations({
         identifier: "v2.health.get",

+ 2 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -15546,6 +15546,8 @@ export type V2HealthGetResponses = {
    */
   200: {
     healthy: true
+    version: string
+    pid: number
   }
 }
 

+ 1 - 0
packages/server/package.json

@@ -12,6 +12,7 @@
     "typecheck": "tsgo --noEmit"
   },
   "dependencies": {
+    "@effect/platform-node": "catalog:",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/protocol": "workspace:*",
     "@opencode-ai/simulation": "workspace:*",

+ 4 - 1
packages/server/src/handlers/health.ts

@@ -1,7 +1,10 @@
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
 
 export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
-  handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })),
+  handlers.handle("health.get", () =>
+    Effect.succeed({ healthy: true as const, version: InstallationVersion, pid: process.pid }),
+  ),
 )

+ 64 - 0
packages/server/src/process.ts

@@ -0,0 +1,64 @@
+export * as ServerProcess from "./process"
+
+import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node"
+import { Credential } from "@opencode-ai/core/credential"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { Project } from "@opencode-ai/core/project"
+import { HealthGroup } from "@opencode-ai/protocol/groups/health"
+import { Context, Effect, Layer, Option } from "effect"
+import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
+import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
+import { createServer } from "node:http"
+import { ServerAuth } from "./auth"
+import { createRoutes } from "./routes"
+
+export type Options = {
+  readonly hostname: string
+  readonly port: Option.Option<number>
+  readonly password: string
+}
+
+const ReadinessApi = HttpApi.make("readiness").add(HealthGroup)
+
+export const start = Effect.fn("ServerProcess.start")(function* (options: Options) {
+  if (!options.password) return yield* Effect.fail(new Error("Missing server password"))
+  const address = yield* listen(options)
+  yield* Effect.gen(function* () {
+    const client = yield* HttpApiClient.make(ReadinessApi, {
+      baseUrl: HttpServer.formatAddress(address),
+      transformClient: HttpClient.mapRequest((request) =>
+        HttpClientRequest.setHeader(
+          request,
+          "authorization",
+          ServerAuth.header({ username: "opencode", password: options.password }) ?? "",
+        ),
+      ),
+    })
+    yield* client["server.health"]["health.get"]({})
+  }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp))
+  return address
+})
+
+function listen(options: Options) {
+  if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password)
+  const next = (port: number): ReturnType<typeof bind> =>
+    bind(options.hostname, port, options.password).pipe(
+      Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
+    )
+  return next(4096)
+}
+
+function bind(hostname: string, port: number, password: string) {
+  const server = createServer()
+  return Layer.build(
+    HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
+      Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
+      Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
+    ),
+  ).pipe(
+    Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
+    Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
+  )
+}

+ 2 - 0
packages/tui/package.json

@@ -38,9 +38,11 @@
     "./plugin/command-shim": "./src/plugin/command-shim.ts",
     "./parsers-config": "./src/parsers-config.ts",
     "./util/error": "./src/util/error.ts",
+    "./util/filetype": "./src/util/filetype.ts",
     "./util/locale": "./src/util/locale.ts",
     "./util/persistence": "./src/util/persistence.ts",
     "./util/record": "./src/util/record.ts",
+    "./util/session": "./src/util/session.ts",
     "./logo": "./src/logo.ts",
     "./ui/dialog": "./src/ui/dialog.tsx",
     "./ui/spinner": "./src/ui/spinner.ts",