diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts
index 76f6276af5..18b16c7281 100644
--- a/packages/opencode/src/cli/cmd/serve.ts
+++ b/packages/opencode/src/cli/cmd/serve.ts
@@ -1,24 +1,42 @@
import { Effect } from "effect"
import { Server } from "../../server/server"
+import { ServerDiscovery } from "@/cli/server-discovery"
import { effectCmd } from "../effect-cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
export const ServeCommand = effectCmd({
command: "serve",
- builder: (yargs) => withNetworkOptions(yargs),
+ builder: (yargs) =>
+ withNetworkOptions(yargs).option("discoverable", {
+ type: "boolean",
+ describe: "write this server to the local discovery file for default TUI startup",
+ default: false,
+ }),
describe: "starts a headless opencode server",
// Server loads instances per-request via x-opencode-directory header — no
// need for an ambient project InstanceContext at startup.
instance: false,
- handler: Effect.fn("Cli.serve")(function* (args) {
- if (!Flag.OPENCODE_SERVER_PASSWORD) {
- console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
- }
- const opts = yield* resolveNetworkOptions(args)
- const server = yield* Effect.promise(() => Server.listen(opts))
- console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
+ handler: (args) =>
+ Effect.gen(function* () {
+ if (!Flag.OPENCODE_SERVER_PASSWORD) {
+ console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
+ }
+ const opts = yield* resolveNetworkOptions(args)
+ const server = yield* Effect.promise(() => Server.listen(opts))
+ const discovery = args.discoverable ? yield* ServerDiscovery.Service : undefined
+ if (discovery) {
+ yield* discovery.write(server.url)
+ process.on("exit", ServerDiscovery.removeSync)
+ }
+ console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
- yield* Effect.never
- }),
+ yield* Effect.never.pipe(
+ Effect.ensuring(
+ discovery
+ ? discovery.remove().pipe(Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))))
+ : Effect.void,
+ ),
+ )
+ }).pipe(Effect.provide(ServerDiscovery.defaultLayer)),
})
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
index ebc65a45b7..f72631f0ca 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -3,12 +3,14 @@ import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
+import { useSDK } from "@tui/context/sdk"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
+ const sdk = useSDK()
const { theme } = useTheme()
const dialog = useDialog()
@@ -50,6 +52,10 @@ export function DialogStatus() {
esc
+
+ Server{" "}
+ {sdk.url === "http://opencode.internal" ? "Embedded" : sdk.url}
+
0} fallback={No MCP Servers}>
{Object.keys(sync.data.mcp).length} MCP Servers
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 382147d918..5af159e0c4 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -9,6 +9,8 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
+import { ServerAuth } from "@/server/auth"
+import { ServerDiscovery } from "@/cli/server-discovery"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
@@ -197,16 +199,26 @@ export const TuiThreadCommand = cmd({
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
+ const discovered = external ? undefined : await ServerDiscovery.find()
const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
+ headers: ServerAuth.headers(),
events: undefined,
}
+ : discovered
+ ? {
+ url: discovered,
+ fetch: undefined,
+ headers: ServerAuth.headers(),
+ events: undefined,
+ }
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
+ headers: undefined,
events: createEventSource(client),
}
@@ -216,6 +228,7 @@ export const TuiThreadCommand = cmd({
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
+ headers: transport.headers,
})
} catch (error) {
UI.error(errorMessage(error))
@@ -241,6 +254,7 @@ export const TuiThreadCommand = cmd({
config,
directory: cwd,
fetch: transport.fetch,
+ headers: transport.headers,
events: transport.events,
args: {
continue: args.continue,
diff --git a/packages/opencode/src/cli/server-discovery.ts b/packages/opencode/src/cli/server-discovery.ts
new file mode 100644
index 0000000000..8ba7289391
--- /dev/null
+++ b/packages/opencode/src/cli/server-discovery.ts
@@ -0,0 +1,112 @@
+export * as ServerDiscovery from "./server-discovery"
+
+import { makeRuntime } from "@/effect/run-service"
+import { ServerAuth } from "@/server/auth"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Global } from "@opencode-ai/core/global"
+import { Context, Effect, Layer, Option, Schema } from "effect"
+import { readFileSync, unlinkSync } from "fs"
+import path from "path"
+
+export const file = path.join(Global.Path.state, "server.json")
+
+const Entry = Schema.Struct({
+ url: Schema.String,
+ pid: Schema.Number,
+})
+type Entry = typeof Entry.Type
+const decodeEntry = Schema.decodeUnknownOption(Entry)
+
+export interface Interface {
+ readonly write: (url: URL) => Effect.Effect
+ readonly remove: () => Effect.Effect
+ readonly find: () => Effect.Effect
+}
+
+export class Service extends Context.Service()("@opencode/CliServerDiscovery") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+
+ const read = Effect.fn("CliServerDiscovery.read")(function* () {
+ const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ return Option.getOrUndefined(decodeEntry(entry))
+ })
+
+ const remove = Effect.fn("CliServerDiscovery.remove")(function* () {
+ const entry = yield* read()
+ if (entry?.pid !== process.pid) return
+ yield* fs.remove(file).pipe(Effect.ignore)
+ })
+
+ const removeStale = Effect.fn("CliServerDiscovery.removeStale")(function* (entry: Entry) {
+ const current = yield* read()
+ if (current?.pid !== entry.pid || current.url !== entry.url) return
+ yield* fs.remove(file).pipe(Effect.ignore)
+ })
+
+ return Service.of({
+ write: Effect.fn("CliServerDiscovery.write")(function* (url) {
+ yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie)
+ }),
+ remove,
+ find: Effect.fn("CliServerDiscovery.find")(function* () {
+ const entry = yield* read()
+ if (!entry) return undefined
+ const url = yield* healthy(entry.url)
+ if (url) return url
+ yield* removeStale(entry)
+ }),
+ })
+ }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export const find = () => runPromise((discovery) => discovery.find())
+
+export function removeSync() {
+ const entry = readSync()
+ if (entry?.pid !== process.pid) return
+ try {
+ unlinkSync(file)
+ } catch {}
+}
+
+function readSync() {
+ try {
+ return Option.getOrUndefined(decodeEntry(JSON.parse(readFileSync(file, "utf8"))))
+ } catch {
+ return undefined
+ }
+}
+
+function healthy(input: string) {
+ return Effect.tryPromise({
+ try: async () => {
+ const url = new URL(input)
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
+ const response = await fetch(new URL("/global/health", url), {
+ headers: ServerAuth.headers(),
+ signal: AbortSignal.timeout(1000),
+ })
+ if (!response.ok) return undefined
+ const body = (await response.json()) as unknown
+ if (typeof body === "object" && body !== null && "healthy" in body && body.healthy === true) {
+ return url.toString()
+ }
+ },
+ catch: () => undefined,
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)))
+}
+
+function localURL(url: URL) {
+ const result = new URL(url)
+ if (result.hostname === "0.0.0.0") result.hostname = "127.0.0.1"
+ if (result.hostname === "::") result.hostname = "::1"
+ return result
+}
diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap
index b7148ebcee..16f416d2c5 100644
--- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap
+++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap
@@ -219,18 +219,20 @@ exports[`opencode CLI help-text snapshots every documented command emits stable
starts a headless opencode server
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]
- --port port to listen on [number] [default: 0]
- --hostname hostname to listen on [string] [default: "127.0.0.1"]
- --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
+ -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]
+ --port port to listen on [number] [default: 0]
+ --hostname hostname to listen on [string] [default: "127.0.0.1"]
+ --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0)
[boolean] [default: false]
- --mdns-domain custom domain name for mDNS service (default: opencode.local)
+ --mdns-domain custom domain name for mDNS service (default: opencode.local)
[string] [default: "opencode.local"]
- --cors additional domains to allow for CORS [array] [default: []]"
+ --cors additional domains to allow for CORS [array] [default: []]
+ --discoverable write this server to the local discovery file for default TUI startup
+ [boolean] [default: false]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `