From 13822b94240ee6f2b09f88e87bab782d76c78d2d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 15 May 2026 20:25:59 -0400 Subject: [PATCH 1/2] 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 76f6276af5da..732aebcf4cfa 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 7230dae16ae4..3c6fb280c72a 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 0ce876ddc65a..fe631853cb82 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 000000000000..6d739c3983f8 --- /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/2] 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 732aebcf4cfa..18b16c72817f 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 3c6fb280c72a..5914fd7ada83 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 6d739c3983f8..8ba728939166 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 fe631853cb82..0ce876ddc65a 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 })