From 13822b94240ee6f2b09f88e87bab782d76c78d2d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 15 May 2026 20:25:59 -0400 Subject: [PATCH 1/4] feat(cli): discover running serve instances --- packages/opencode/src/cli/cmd/serve.ts | 22 +++- packages/opencode/src/cli/cmd/tui/thread.ts | 14 +++ packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/server/discovery.ts | 112 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/server/discovery.ts diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 76f6276af5d..732aebcf4cf 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,12 +1,18 @@ import { Effect } from "effect" import { Server } from "../../server/server" +import { ServerDiscovery } from "@/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. @@ -17,8 +23,20 @@ export const ServeCommand = effectCmd({ } const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) + if (args.discoverable) { + yield* ServerDiscovery.Service.use((discovery) => 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( + args.discoverable + ? ServerDiscovery.Service.use((discovery) => discovery.remove()).pipe( + Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))), + ) + : Effect.void, + ), + ) }), }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7230dae16ae..3c6fb280c72 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 "@/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)) @@ -239,6 +252,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/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 0ce876ddc65..fe631853cb8 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -58,6 +58,7 @@ import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" +import { ServerDiscovery } from "@/server/discovery" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -114,6 +115,7 @@ export const AppLayer = Layer.mergeAll( SyncEvent.defaultLayer, EventV2Bridge.defaultLayer, DataMigration.defaultLayer, + ServerDiscovery.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/server/discovery.ts b/packages/opencode/src/server/discovery.ts new file mode 100644 index 00000000000..6d739c3983f --- /dev/null +++ b/packages/opencode/src/server/discovery.ts @@ -0,0 +1,112 @@ +export * as ServerDiscovery from "./discovery" + +import { ServerAuth } from "@/server/auth" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Context, Effect, Layer, Option, Schema } from "effect" +import path from "path" +import { readFileSync, unlinkSync } from "fs" + +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/ServerDiscovery") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + const read = Effect.fn("ServerDiscovery.read")(function* () { + const entry = yield* fs.readJson(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + return Option.getOrUndefined(decodeEntry(entry)) + }) + + const remove = Effect.fn("ServerDiscovery.remove")(function* () { + const entry = yield* read() + if (entry?.pid !== process.pid) return + yield* fs.remove(file).pipe(Effect.ignore) + }) + + const removeStale = Effect.fn("ServerDiscovery.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("ServerDiscovery.write")(function* (url) { + yield* fs.writeJson(file, { url: localURL(url).toString(), pid: process.pid }, 0o600).pipe(Effect.orDie) + }), + remove, + find: Effect.fn("ServerDiscovery.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 +} From 26f64f29fbf60798f1b49b3b068564345ae1dda9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 15 May 2026 20:28:53 -0400 Subject: [PATCH 2/4] refactor(cli): scope server discovery to CLI --- packages/opencode/src/cli/cmd/serve.ts | 44 +++++++++---------- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- .../discovery.ts => cli/server-discovery.ts} | 18 ++++---- packages/opencode/src/effect/app-runtime.ts | 2 - 4 files changed, 32 insertions(+), 34 deletions(-) rename packages/opencode/src/{server/discovery.ts => cli/server-discovery.ts} (87%) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 732aebcf4cf..18b16c72817 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { Server } from "../../server/server" -import { ServerDiscovery } from "@/server/discovery" +import { ServerDiscovery } from "@/cli/server-discovery" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" @@ -17,26 +17,26 @@ export const ServeCommand = effectCmd({ // 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)) - if (args.discoverable) { - yield* ServerDiscovery.Service.use((discovery) => discovery.write(server.url)) - process.on("exit", ServerDiscovery.removeSync) - } - 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.pipe( - Effect.ensuring( - args.discoverable - ? ServerDiscovery.Service.use((discovery) => discovery.remove()).pipe( - Effect.ensuring(Effect.sync(() => process.off("exit", ServerDiscovery.removeSync))), - ) - : Effect.void, - ), - ) - }), + 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/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3c6fb280c72..5914fd7ada8 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -10,7 +10,7 @@ import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util/filesystem" import { ServerAuth } from "@/server/auth" -import { ServerDiscovery } from "@/server/discovery" +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" diff --git a/packages/opencode/src/server/discovery.ts b/packages/opencode/src/cli/server-discovery.ts similarity index 87% rename from packages/opencode/src/server/discovery.ts rename to packages/opencode/src/cli/server-discovery.ts index 6d739c3983f..8ba72893916 100644 --- a/packages/opencode/src/server/discovery.ts +++ b/packages/opencode/src/cli/server-discovery.ts @@ -1,12 +1,12 @@ -export * as ServerDiscovery from "./discovery" +export * as ServerDiscovery from "./server-discovery" -import { ServerAuth } from "@/server/auth" 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 path from "path" import { readFileSync, unlinkSync } from "fs" +import path from "path" export const file = path.join(Global.Path.state, "server.json") @@ -23,36 +23,36 @@ export interface Interface { readonly find: () => Effect.Effect } -export class Service extends Context.Service()("@opencode/ServerDiscovery") {} +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("ServerDiscovery.read")(function* () { + 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("ServerDiscovery.remove")(function* () { + 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("ServerDiscovery.removeStale")(function* (entry: Entry) { + 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("ServerDiscovery.write")(function* (url) { + 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("ServerDiscovery.find")(function* () { + find: Effect.fn("CliServerDiscovery.find")(function* () { const entry = yield* read() if (!entry) return undefined const url = yield* healthy(entry.url) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index fe631853cb8..0ce876ddc65 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -58,7 +58,6 @@ import { DataMigration } from "@/data-migration" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { RuntimeFlags } from "@/effect/runtime-flags" -import { ServerDiscovery } from "@/server/discovery" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -115,7 +114,6 @@ export const AppLayer = Layer.mergeAll( SyncEvent.defaultLayer, EventV2Bridge.defaultLayer, DataMigration.defaultLayer, - ServerDiscovery.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) From b54cffc3e1c4cc90df9d1b296ee7c8f96bb2cdfd Mon Sep 17 00:00:00 2001 From: Vogel Date: Fri, 29 May 2026 18:07:02 -0400 Subject: [PATCH 3/4] feat: show the active connected server in the TUI --- .../opencode/src/cli/cmd/tui/component/dialog-status.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 ebc65a45b7d..f72631f0ca6 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 From 4ebd1272f2ec3df1197bcefb622119edde077acb Mon Sep 17 00:00:00 2001 From: Vogel Date: Fri, 29 May 2026 18:26:54 -0400 Subject: [PATCH 4/4] test: update serve help snapshot --- .../__snapshots__/help-snapshots.test.ts.snap | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 22ad59aaa29..572eef981af 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`] = `