فهرست منبع

refactor(cli): centralize server resolution

Dax Raad 1 ماه پیش
والد
کامیت
cccd013801

+ 14 - 17
packages/cli/src/commands/commands.ts

@@ -28,21 +28,25 @@ const MiniParams = {
   ),
 }
 
+const ServerParams = {
+  standalone: Flag.boolean("standalone").pipe(
+    Flag.withDescription("Run with a private server instead of the background service"),
+    Flag.withDefault(false),
+  ),
+  server: Flag.string("server").pipe(
+    Flag.withDescription("Connect to a server URL instead of the background service"),
+    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: {
+    ...ServerParams,
     directory: Argument.string("directory").pipe(
       Argument.withDescription("Directory to start OpenCode in"),
       Argument.optional,
     ),
-    standalone: Flag.boolean("standalone").pipe(
-      Flag.withDescription("Run with a private server instead of the background service"),
-      Flag.withDefault(false),
-    ),
-    server: Flag.string("server").pipe(
-      Flag.withDescription("Connect to a server URL instead of the background service"),
-      Flag.optional,
-    ),
     continue: Flag.boolean("continue").pipe(
       Flag.withAlias("c"),
       Flag.withDescription("Continue the last session"),
@@ -128,6 +132,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
       description: "Start the minimal interactive interface",
       params: {
         ...MiniParams,
+        ...ServerParams,
         project: Argument.string("project").pipe(
           Argument.withDescription("Path to start OpenCode in"),
           Argument.optional,
@@ -139,16 +144,13 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
         ),
         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: {
+        ...ServerParams,
         message: Argument.string("message").pipe(
           Argument.withDescription("Message to send"),
           Argument.variadic({ min: 0 }),
@@ -183,11 +185,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
           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"),

+ 6 - 77
packages/cli/src/commands/handlers/default.ts

@@ -1,4 +1,3 @@
-import { NodeFileSystem } from "@effect/platform-node"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { Global } from "@opencode-ai/core/global"
 import { run } from "@opencode-ai/tui"
@@ -6,11 +5,8 @@ import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
 import { TuiConfig } from "@opencode-ai/tui/config"
 import { Commands } from "../commands"
 import { Runtime } from "../../framework/runtime"
-import { Effect, Option, Redacted } from "effect"
-import { Service } from "@opencode-ai/client/effect"
-import { Env } from "../../env"
-import { ServiceConfig } from "../../services/service-config"
-import { Standalone } from "../../services/standalone"
+import { Effect, Option } from "effect"
+import { Server } from "../../services/server"
 import { Updater } from "../../services/updater"
 
 export default Runtime.handler(Commands, (input) =>
@@ -19,82 +15,15 @@ export default Runtime.handler(Commands, (input) =>
     if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
     const updater = yield* Updater.Service
     yield* updater.check().pipe(Effect.forkScoped)
-    const server = Option.getOrUndefined(input.server)
-    if (server !== undefined && input.standalone)
-      return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
-    const endpoint = yield* Effect.gen(function* () {
-      if (server !== undefined) {
-        const password = yield* Env.password
-        const explicit = {
-          url: server,
-          auth: password
-            ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
-            : undefined,
-        } satisfies Service.Endpoint
-        // Fail loudly before entering the TUI: an explicit server that is
-        // unreachable or rejects auth should not present as reconnect churn.
-        const response = yield* Effect.tryPromise(() =>
-          fetch(new URL("/api/health", server), {
-            headers: Service.headers(explicit),
-            signal: AbortSignal.timeout(5_000),
-          }),
-        ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
-        if (response.status === 401)
-          return yield* Effect.fail(
-            new Error(
-              password
-                ? `Server at ${server} rejected the password`
-                : `Server at ${server} requires a password; set OPENCODE_PASSWORD`,
-            ),
-          )
-        if (!response.ok)
-          return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
-        return explicit
-      }
-      if (input.standalone) return yield* Standalone.start()
-      const options = yield* ServiceConfig.options()
-      const found = yield* Service.discover(options)
-      return found ?? (yield* Service.start(options))
+    const server = yield* Server.resolve({
+      server: Option.getOrUndefined(input.server),
+      standalone: input.standalone,
     })
-    // The TUI re-runs discover whenever its event stream drops. For an explicit
-    // --server or a standalone child the endpoint is fixed, so reconnects
-    // retry the same address; for the managed service discovery re-reads the
-    // registration and may start a replacement.
-    const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined
-    // Only startup enforces the CLI version. A reconnect must accept a server
-    // replaced by another client or the two clients will restart it forever.
-    const reconnectOptions = serviceOptions ? { ...serviceOptions, version: undefined } : undefined
-    const discover = reconnectOptions
-      ? () =>
-          Effect.runPromise(
-            Effect.gen(function* () {
-              const found = yield* Service.discover(reconnectOptions)
-              return found ?? (yield* Service.start(reconnectOptions))
-            }).pipe(Effect.provide(NodeFileSystem.layer)),
-          )
-      : undefined
-    // Restart the managed service in place; start() resolves once the
-    // replacement is healthy and the reconnect loop reattaches on its own.
-    // Only meaningful in service mode: --server is not ours to restart and a
-    // standalone child cannot be respawned.
-    const reload = serviceOptions
-      ? () =>
-          Effect.runPromise(
-            Effect.gen(function* () {
-              yield* Service.stop(serviceOptions)
-              yield* Service.start(serviceOptions)
-            }).pipe(Effect.provide(NodeFileSystem.layer)),
-          )
-      : undefined
     const config = TuiConfig.resolve({}, { terminalSuspend: false })
     let disposeSlots: (() => void) | undefined
     const runFork = Effect.runForkWith(yield* Effect.context())
     yield* run({
-      server: {
-        endpoint,
-        discover,
-        reload,
-      },
+      server,
       args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
       config,
       log: (level, message, tags) => {

+ 8 - 12
packages/cli/src/commands/handlers/mini.ts

@@ -1,25 +1,21 @@
-import { Effect, Option, Redacted } from "effect"
+import { Effect, Option } from "effect"
 import path from "node:path"
 import { Commands } from "../commands"
-import { Env } from "../../env"
 import { Runtime } from "../../framework/runtime"
+import { Server } from "../../services/server"
 
 export default Runtime.handler(Commands.commands.mini, (input) =>
   Effect.gen(function* () {
-    const { runMini } = yield* Effect.promise(() => import("../../mini"))
+    const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
+    yield* Effect.promise(async () => validateMiniTerminal())
     const project = Option.getOrUndefined(input.project)
-    const server = Option.getOrUndefined(input.server)
-    const password = yield* Env.password
+    const serverURL = Option.getOrUndefined(input.server)
+    const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
     yield* Effect.promise(() =>
       runMini({
-        attach: server,
-        password: password ? Redacted.value(password) : undefined,
+        server,
         directory:
-          server !== undefined
-            ? project
-            : project === undefined
-              ? process.cwd()
-              : path.resolve(process.env.PWD ?? process.cwd(), project),
+          project === undefined ? process.cwd() : path.resolve(process.env.PWD ?? process.cwd(), project),
         continue: input.continue,
         session: Option.getOrUndefined(input.session),
         fork: input.fork,

+ 7 - 6
packages/cli/src/commands/handlers/run.ts

@@ -1,15 +1,19 @@
-import { Effect, Option, Redacted } from "effect"
+import { Effect, Option } from "effect"
 import { Commands } from "../commands"
-import { Env } from "../../env"
 import { Runtime } from "../../framework/runtime"
+import { Server } from "../../services/server"
 
 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)
+    const server = yield* Server.resolve({
+      server: Option.getOrUndefined(input.server),
+      standalone: input.standalone,
+    })
     yield* Effect.promise(() =>
       runNonInteractive({
+        server,
         message: [...input.message, ...(separator === -1 ? [] : process.argv.slice(separator + 1))],
         continue: input.continue,
         session: Option.getOrUndefined(input.session),
@@ -19,9 +23,6 @@ export default Runtime.handler(Commands.commands.run, (input) =>
         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,

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

@@ -1,46 +0,0 @@
-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 { Effect } from "effect"
-
-type ConnectOptions = {
-  readonly url: string
-  readonly username?: string
-  readonly password?: string
-}
-
-export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) {
-  const endpoint = {
-    url: options.url,
-    auth:
-      options.password === undefined
-        ? undefined
-        : { type: "basic" as const, username: options.username ?? "opencode", password: options.password },
-  } satisfies Service.Endpoint
-  const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
-  const health = yield* Effect.tryPromise({
-    try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
-    catch: (cause) => connectError(options, cause),
-  })
-  if (health.version !== InstallationVersion)
-    process.stderr.write(
-      `Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
-    )
-  return endpoint
-})
-
-function connectError(options: ConnectOptions, 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"

+ 1 - 1
packages/cli/src/mini/index.ts

@@ -1,4 +1,4 @@
-export { runMini, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
+export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
 export {
   runNonInteractive,
   mergeInput as mergeNonInteractiveInput,

+ 18 - 74
packages/cli/src/mini/mini.ts

@@ -1,20 +1,14 @@
-import { NodeFileSystem } from "@effect/platform-node"
 import { Service } from "@opencode-ai/client/effect"
 import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
-import { Global } from "@opencode-ai/core/global"
-import { Effect } from "effect"
 import path from "node:path"
-import { Daemon } from "../daemon"
-import { ServiceConfig } from "../services/service-config"
+import { Server } from "../services/server"
 import { waitForCatalogReady } from "./catalog.shared"
 import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
 import type { RunInput, RunTuiConfig } from "./types"
 
 export type MiniCommandInput = {
+  server: Server.Resolved
   directory?: string
-  attach?: string
-  password?: string
-  username?: string
   continue?: boolean
   session?: string
   fork?: boolean
@@ -24,7 +18,6 @@ export type MiniCommandInput = {
   replay?: boolean
   replayLimit?: number
   demo?: boolean
-  serverCommand?: ReadonlyArray<string>
   tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
 }
 
@@ -33,47 +26,38 @@ 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 endpointTask = startEndpoint(input)
-  void endpointTask.catch(() => {})
+  const directory = localDirectory(input.directory)
 
   try {
-    if (input.attach) await endpointTask
     const sdk = OpenCode.make({
-      baseUrl: "http://opencode.pending",
-      fetch: deferredFetch(endpointTask),
+      baseUrl: input.server.endpoint.url,
+      headers: Service.headers(input.server.endpoint),
     })
-    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 endpointTask, sdk))
     const model = parseModel(input.model)
     let agentTask: Promise<string | undefined> | undefined
     const resolveAgent = () => {
-      agentTask ??= validateAgent(sdk, resolvedDirectory, input.agent, input.attach)
+      agentTask ??= validateAgent(sdk, directory, input.agent)
       return agentTask
     }
     const resolveSession = async () => {
       const [agent, selected] = await Promise.all([
         resolveAgent(),
-        selectSession(sdk, resolvedDirectory, input, attachedSession),
+        selectSession(sdk, directory, input),
       ])
       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))
+      if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
+      const session = selected ?? (await createSession(sdk, directory, 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)
+    ) => createSession(sdk, directory, next.agent, next.model, next.variant)
     const runtime = await runtimeTask
     await runtime.runInteractiveDeferredMode({
       sdk,
-      directory: resolvedDirectory,
+      directory,
       resolveAgent,
       session: resolveSession,
       createSession: create,
@@ -94,6 +78,10 @@ export async function runMini(input: MiniCommandInput) {
   }
 }
 
+export function validateMiniTerminal() {
+  if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
+}
+
 /** @internal Exported for testing. */
 export function mergeInput(piped: string | undefined, prompt: string | undefined) {
   if (!prompt) return piped || undefined
@@ -102,7 +90,7 @@ export function mergeInput(piped: string | undefined, prompt: string | undefined
 }
 
 function validate(input: MiniCommandInput) {
-  if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
+  validateMiniTerminal()
   if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
     fail("--replay-limit must be a positive integer")
   }
@@ -120,50 +108,6 @@ function localDirectory(directory?: string): string {
   }
 }
 
-function startEndpoint(input: MiniCommandInput): Promise<Service.Endpoint> {
-  if (input.attach) {
-    return Effect.runPromise(
-      Daemon.connect({
-        url: input.attach,
-        password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
-        username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
-      }),
-    )
-  }
-  return Effect.runPromise(
-    Effect.gen(function* () {
-      const options = yield* ServiceConfig.options()
-      return yield* Service.start(
-        input.serverCommand === undefined ? options : { ...options, command: input.serverCommand },
-      )
-    }).pipe(
-      Effect.provide(NodeFileSystem.layer),
-      Effect.provide(Global.layerWith({})),
-    ),
-  )
-}
-
-function deferredFetch(endpointTask: Promise<Service.Endpoint>): typeof globalThis.fetch {
-  const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
-    const endpoint = await endpointTask
-    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(Service.headers(endpoint))) headers.set(key, value)
-    return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers })
-  }
-  return fetch as typeof globalThis.fetch
-}
-
-async function remoteDirectory(
-  endpoint: Service.Endpoint,
-  sdk: OpenCodeClient,
-): Promise<string> {
-  const location = await sdk.location.get()
-  if (!location.directory) throw new Error(`Failed to resolve remote directory from ${endpoint.url}`)
-  return location.directory
-}
-
 function parseModel(value?: string): RunInput["model"] {
   if (!value) return
   const [providerID, ...rest] = value.split("/")
@@ -172,7 +116,7 @@ function parseModel(value?: string): RunInput["model"] {
   return { providerID, modelID }
 }
 
-async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string, attach?: string) {
+async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
   if (!name) return
   const deadline = Date.now() + 5_000
   let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
@@ -187,7 +131,7 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
     await Bun.sleep(25)
   }
   if (!agents) {
-    warning(`failed to list agents${attach ? ` from ${attach}` : ""}. Falling back to default agent`)
+    warning("failed to list agents. Falling back to default agent")
     return
   }
   warning(`agent "${name}" not found. Falling back to default agent`)

+ 13 - 55
packages/cli/src/mini/run.ts

@@ -1,21 +1,17 @@
-import { NodeFileSystem } from "@effect/platform-node"
 import { Service } from "@opencode-ai/client/effect"
 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 { ServiceConfig } from "../services/service-config"
-import { Standalone } from "../services/standalone"
+import { Server } from "../services/server"
 import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
 import { runNonInteractivePrompt } from "./noninteractive"
 import { toolInlineInfo } from "./tool"
 import { UI } from "./ui"
 
 export type RunCommandInput = {
+  server: Server.Resolved
   message: string[]
   continue?: boolean
   session?: string
@@ -25,14 +21,9 @@ export type RunCommandInput = {
   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 = {
@@ -56,24 +47,12 @@ export function runNonInteractive(input: RunCommandInput) {
 async function run(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 directory = localDirectory(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 files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
   const prepared = { directory, message, files }
-  if (input.standaloneCommand)
-    return Effect.runPromise(
-      Effect.scoped(
-        Effect.gen(function* () {
-          const endpoint = yield* Standalone.start({ command: input.standaloneCommand })
-          yield* Effect.promise(() => execute(input, prepared, endpoint))
-        }),
-      ),
-    )
-  const endpoint = await startEndpoint(input)
-  return execute(input, prepared, endpoint)
+  return execute(input, prepared, input.server.endpoint)
 }
 
 async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
@@ -100,7 +79,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
     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 agent = await validateAgent(client, cwd, input.agent)
   const selected =
     session ??
     (await client.session.create({
@@ -126,7 +105,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
     thinking: input.thinking ?? false,
     format: input.format,
     dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
-    attached: !input.standaloneCommand,
+    attached: true,
     renderTool,
     renderToolError,
   }).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
@@ -154,33 +133,13 @@ function formatMessage(message: string[]) {
   return value || undefined
 }
 
-function localDirectory(directory: string | undefined, root: string) {
+function localDirectory(root: string) {
   try {
-    process.chdir(directory ? (path.isAbsolute(directory) ? directory : path.join(root, directory)) : root)
+    process.chdir(root)
     return process.cwd()
   } catch {
-    fail(`Failed to change directory to ${directory}`)
-  }
-}
-
-function startEndpoint(input: RunCommandInput) {
-  if (input.server) {
-    return Effect.runPromise(
-      Daemon.connect({
-        url: input.server,
-        password: input.password,
-        username: input.username,
-      }),
-    )
+    fail(`Failed to change directory to ${root}`)
   }
-  return Effect.runPromise(
-    Effect.gen(function* () {
-      return yield* Service.start(yield* ServiceConfig.options())
-    }).pipe(
-      Effect.provide(NodeFileSystem.layer),
-      Effect.provide(Global.layerWith({})),
-    ),
-  )
 }
 
 function parseModel(value?: string) {
@@ -191,11 +150,11 @@ function parseModel(value?: string) {
   return { providerID, modelID }
 }
 
-async function validateAgent(client: OpenCodeClient, directory: string, name?: string, server?: string) {
+async function validateAgent(client: OpenCodeClient, directory: string, name?: 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`)
+    warning("failed to list agents. Falling back to default agent")
     return
   }
   const agent = agents.find((item) => item.id === name)
@@ -223,12 +182,11 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
   return client.session.fork({ sessionID: selected.id })
 }
 
-async function prepareFile(input: string, directory: string, remote: boolean): Promise<FilePart> {
+async function prepareFile(input: string, directory: string): 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))

+ 77 - 0
packages/cli/src/services/server.ts

@@ -0,0 +1,77 @@
+import { NodeFileSystem } from "@effect/platform-node"
+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 { Effect, Redacted } from "effect"
+import { Env } from "../env"
+import { ServiceConfig } from "./service-config"
+import { Standalone } from "./standalone"
+
+export type Args = {
+  readonly server?: string
+  readonly standalone?: boolean
+}
+
+export type Resolved = {
+  readonly endpoint: Service.Endpoint
+  readonly discover?: () => Promise<Service.Endpoint>
+  readonly reload?: () => Promise<void>
+}
+
+export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
+  if (args.server !== undefined && args.standalone)
+    return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
+  if (args.server !== undefined) {
+    const password = yield* Env.password
+    const endpoint = {
+      url: args.server,
+      auth: password
+        ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
+        : undefined,
+    } satisfies Service.Endpoint
+    const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
+    const health = yield* Effect.tryPromise({
+      try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
+      catch: (cause) => connectError(endpoint, cause),
+    })
+    if (health.version !== InstallationVersion)
+      process.stderr.write(
+        `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
+      )
+    return { endpoint } satisfies Resolved
+  }
+  if (args.standalone) {
+    return { endpoint: yield* Standalone.start() } satisfies Resolved
+  }
+
+  const options = yield* ServiceConfig.options()
+  const endpoint = yield* Service.start(options)
+  const reconnectOptions = { ...options, version: undefined }
+  return {
+    endpoint,
+    discover: () => Effect.runPromise(Service.start(reconnectOptions).pipe(Effect.provide(NodeFileSystem.layer))),
+    reload: () =>
+      Effect.runPromise(
+        Effect.gen(function* () {
+          yield* Service.stop(options)
+          yield* Service.start(options)
+        }).pipe(Effect.provide(NodeFileSystem.layer)),
+      ),
+  } satisfies Resolved
+})
+
+function connectError(endpoint: Service.Endpoint, cause: unknown) {
+  if (isUnauthorizedError(cause)) {
+    return new Error(
+      endpoint.auth === undefined
+        ? `Server at ${endpoint.url} requires a password; set OPENCODE_PASSWORD`
+        : `Server at ${endpoint.url} rejected the password`,
+      { cause },
+    )
+  }
+  if (cause instanceof ClientError && cause.reason === "Transport")
+    return new Error(`Could not reach server at ${endpoint.url}`, { cause })
+  return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
+}
+
+export * as Server from "./server"

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

@@ -71,6 +71,8 @@ describe("mini command", () => {
         const url = new URL(request.url)
         if (url.pathname === "/api/health")
           return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
+        if (url.pathname === "/api/location")
+          return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
         if (url.pathname === "/api/model") {
           modelRequests++
           return Response.json({
@@ -87,8 +89,6 @@ describe("mini command", () => {
         "run",
         "--server",
         server.url.toString(),
-        "--dir",
-        process.cwd(),
         "--model",
         "definitely/missing",
         "hi",
@@ -104,7 +104,8 @@ describe("mini command", () => {
   test("reports pre-admission errors as JSON", async () => {
     const server = Bun.serve({
       port: 0,
-      fetch() {
+      fetch(request) {
+        if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
         return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
       },
     })
@@ -116,7 +117,7 @@ describe("mini command", () => {
       expect(JSON.parse(result.stdout)).toMatchObject({
         type: "error",
         sessionID: "",
-        error: { type: "unknown", message: "Failed to resolve server directory" },
+        error: { type: "unknown", message: "UnexpectedStatus" },
       })
     } finally {
       server.stop(true)

+ 1 - 0
packages/opencode/src/cli/cmd/attach.ts

@@ -84,6 +84,7 @@ export const AttachCommand = cmd({
     const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
     await Effect.runPromise(
       run({
+        // @ts-expect-error V1 does not consume the V2-only server input.
         client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
         api: OpenCode.make({ baseUrl: args.url, headers }),
         config,

+ 1 - 0
packages/opencode/src/cli/cmd/run.ts

@@ -112,6 +112,7 @@ export const RunCommand = effectCmd({
         file: args.file ?? [],
         title: args.title,
         server: args.server ?? args.attach,
+        // @ts-expect-error V1 does not consume the V2-only resolved server input.
         password: args.password ?? process.env.OPENCODE_PASSWORD ?? process.env.OPENCODE_SERVER_PASSWORD,
         username: args.username ?? process.env.OPENCODE_SERVER_USERNAME,
         directory: args.dir,

+ 1 - 0
packages/opencode/src/cli/cmd/tui.ts

@@ -159,6 +159,7 @@ export const TuiThreadCommand = cmd({
         const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
          await Effect.runPromise(
           run({
+            // @ts-expect-error V1 does not consume the V2-only server input.
             client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
             api: OpenCode.make({ baseUrl: url, headers }),
             async onSnapshot() {