From 2719140b569b6b55667ba91fcdecc87c64639bc7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:23:09 -0400 Subject: [PATCH 01/13] serve: add socket listener mode --- packages/opencode/src/cli/cmd/serve.ts | 38 +++++++++++++++++-- packages/opencode/src/server/server.ts | 37 ++++++++++++++++-- .../test/server/httpapi-listen.test.ts | 36 ++++++++++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 76f6276af5d..2955715f9f3 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,12 +1,17 @@ import { Effect } from "effect" import { Server } from "../../server/server" -import { effectCmd } from "../effect-cmd" +import { effectCmd, fail } 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("socket", { + type: "string", + describe: "Unix socket path or Windows named pipe name/path to listen on", + }), 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. @@ -15,10 +20,37 @@ export const ServeCommand = effectCmd({ if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } + if (args.socket) { + const conflicts = explicitNetworkConflicts() + if (conflicts.length) yield* fail(`--socket cannot be used with ${conflicts.join(", ")}`) + } const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => Server.listen(opts)) + const server = yield* Effect.promise(() => + Server.listen(args.socket ? { ...opts, socket: resolveSocketPath(args.socket) } : opts), + ) + if (server.socket) { + console.log(`opencode server listening on socket ${server.socket}`) + yield* Effect.never + } console.log(`opencode server listening on http://${server.hostname}:${server.port}`) yield* Effect.never }), }) + +function resolveSocketPath(input: string) { + if (process.platform !== "win32") return input + const lower = input.toLowerCase() + if (lower.startsWith("\\\\.\\pipe\\") || lower.startsWith("\\\\?\\pipe\\")) return input + const name = input + .replace(/^[a-zA-Z]:/, (drive) => drive.slice(0, 1)) + .replace(/[\\/:]+/g, "-") + .replace(/^-+|-+$/g, "") + return `\\\\.\\pipe\\${name || "opencode"}` +} + +function explicitNetworkConflicts() { + return ["--port", "--hostname", "--mdns", "--mdns-domain"].filter((flag) => + process.argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`)), + ) +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9a448b78d62..ffbbecc7091 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -23,6 +23,7 @@ export type Listener = { hostname: string port: number url: URL + socket?: string stop: (close?: boolean) => Promise } @@ -34,6 +35,7 @@ type ServerApp = { type ListenOptions = CorsOptions & { port: number hostname: string + socket?: string mdns?: boolean mdnsDomain?: string } @@ -78,6 +80,7 @@ export async function listen(opts: ListenOptions): Promise { hostname: listener.hostname, port: listener.port, url: listener.url, + socket: listener.socket, stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), } } @@ -85,6 +88,20 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) + if (opts.socket) { + const address = yield* unixAddress(state) + const listenerUrl = makeURL("localhost", 0) + url = listenerUrl + + return { + hostname: "localhost", + port: 0, + socket: address.path, + url: listenerUrl, + stop: yield* makeStop(state, Effect.void), + } + } + const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) url = listenerUrl @@ -107,7 +124,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableListenLog: true, }).pipe( Layer.provideMerge(WebSocketTracker.layer), - Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), + Layer.provideMerge(serverLayer(opts.socket ? { socket: opts.socket } : { port, hostname: opts.hostname })), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default // `ConfigProvider` snapshots `process.env` on first read and caches the @@ -118,6 +135,7 @@ function listenerLayer(opts: ListenOptions, port: number) { } function startWithPortFallback(opts: ListenOptions) { + if (opts.socket) return startListener(opts, opts.port) if (opts.port !== 0) return startListener(opts, opts.port) // Match the legacy listener port-resolution behavior: explicit `0` prefers // 4096 first, then any free port. @@ -148,10 +166,18 @@ function tcpAddress(state: ListenerState) { }) } +function unixAddress(state: ListenerState) { + return Effect.gen(function* () { + if (state.server.address._tag === "UnixAddress") return state.server.address + yield* Scope.close(state.scope, Exit.void).pipe(Effect.ignore) + return yield* Effect.die(new Error(`Unexpected HttpServer address tag: ${state.server.address._tag}`)) + }) +} + function makeURL(hostname: string, port: number) { const result = new URL("http://localhost") result.hostname = hostname - result.port = String(port) + if (port) result.port = String(port) return result } @@ -188,7 +214,7 @@ function forceClose(state: ListenerState) { return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) } -function serverLayer(opts: { port: number; hostname: string }) { +function serverLayer(opts: { port: number; hostname: string } | { socket: string }) { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) @@ -203,7 +229,10 @@ function serverLayer(opts: { port: number; hostname: string }) { }) as typeof server.close return Layer.mergeAll( - NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + NodeHttpServer.layer(() => server, { + ...("socket" in opts ? { path: opts.socket } : { port: opts.port, host: opts.hostname }), + gracefulShutdownTimeout: "1 second", + }), Layer.succeed(ListenerServerService)( ListenerServerService.of({ closeAll: Effect.sync(() => { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index f155521384a..f582de13e7a 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" +import http from "node:http" import net from "node:net" +import path from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" @@ -285,6 +287,25 @@ describe("HttpApi Server.listen", () => { ).rejects.toThrow() }) + test("listens on a socket path", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const socket = + process.platform === "win32" + ? `\\\\.\\pipe\\opencode-test-${process.pid}-${Date.now()}` + : path.join(tmp.path, "opencode.sock") + const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, socket }) + try { + expect(listener.port).toBe(0) + expect(listener.socket).toBe(socket) + + const response = await requestSocketRoot(socket) + expect(response.statusCode).toBe(200) + expect(response.body).toContain("OpenCode") + } finally { + await stop(listener, "timed out cleaning up socket listener") + } + }) + test("default in-process handler does not emit Effect HTTP response logs", async () => { let output = "" // oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr. @@ -433,3 +454,18 @@ function occupyPort(port: number) { server.listen(port, "127.0.0.1", () => resolve(server)) }) } + +function requestSocketRoot(socket: string) { + return new Promise<{ statusCode?: number; body: string }>((resolve, reject) => { + const request = http.request( + { socketPath: socket, path: "/", method: "GET" }, + (response) => { + const chunks: Buffer[] = [] + response.on("data", (chunk: Buffer) => chunks.push(chunk)) + response.on("end", () => resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString() })) + }, + ) + request.on("error", reject) + request.end() + }) +} From e3cbfa1c05e25afe2a66637b15a700ad6d90c063 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:40:19 -0400 Subject: [PATCH 02/13] serve: simplify socket listener setup --- packages/opencode/src/server/server.ts | 56 +++++++++++++------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ffbbecc7091..2234722928a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -88,21 +88,11 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) - if (opts.socket) { - const address = yield* unixAddress(state) - const listenerUrl = makeURL("localhost", 0) - url = listenerUrl - - return { - hostname: "localhost", - port: 0, - socket: address.path, - url: listenerUrl, - stop: yield* makeStop(state, Effect.void), - } - } + const address = state.server.address + + if (address._tag === "UnixAddress") return yield* makeSocketListener(state, address.path) + if (opts.socket) return yield* unexpectedAddress(state) - const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) url = listenerUrl @@ -124,7 +114,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableListenLog: true, }).pipe( Layer.provideMerge(WebSocketTracker.layer), - Layer.provideMerge(serverLayer(opts.socket ? { socket: opts.socket } : { port, hostname: opts.hostname })), + Layer.provideMerge(serverLayer(opts, port)), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default // `ConfigProvider` snapshots `process.env` on first read and caches the @@ -135,7 +125,7 @@ function listenerLayer(opts: ListenOptions, port: number) { } function startWithPortFallback(opts: ListenOptions) { - if (opts.socket) return startListener(opts, opts.port) + if (opts.socket) return startListener(opts, 0) if (opts.port !== 0) return startListener(opts, opts.port) // Match the legacy listener port-resolution behavior: explicit `0` prefers // 4096 first, then any free port. @@ -158,26 +148,32 @@ function startListener(opts: ListenOptions, port: number) { ) } -function tcpAddress(state: ListenerState) { +function makeSocketListener(state: ListenerState, socket: string) { return Effect.gen(function* () { - if (state.server.address._tag === "TcpAddress") return state.server.address - yield* Scope.close(state.scope, Exit.void).pipe(Effect.ignore) - return yield* Effect.die(new Error(`Unexpected HttpServer address tag: ${state.server.address._tag}`)) + const listenerUrl = makeURL("localhost") + url = listenerUrl + + return { + hostname: "localhost", + port: 0, + socket, + url: listenerUrl, + stop: yield* makeStop(state, Effect.void), + } }) } -function unixAddress(state: ListenerState) { +function unexpectedAddress(state: ListenerState) { return Effect.gen(function* () { - if (state.server.address._tag === "UnixAddress") return state.server.address yield* Scope.close(state.scope, Exit.void).pipe(Effect.ignore) return yield* Effect.die(new Error(`Unexpected HttpServer address tag: ${state.server.address._tag}`)) }) } -function makeURL(hostname: string, port: number) { +function makeURL(hostname: string, port?: number) { const result = new URL("http://localhost") result.hostname = hostname - if (port) result.port = String(port) + if (port !== undefined) result.port = String(port) return result } @@ -214,7 +210,7 @@ function forceClose(state: ListenerState) { return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) } -function serverLayer(opts: { port: number; hostname: string } | { socket: string }) { +function serverLayer(opts: ListenOptions, port: number) { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) @@ -229,10 +225,7 @@ function serverLayer(opts: { port: number; hostname: string } | { socket: string }) as typeof server.close return Layer.mergeAll( - NodeHttpServer.layer(() => server, { - ...("socket" in opts ? { path: opts.socket } : { port: opts.port, host: opts.hostname }), - gracefulShutdownTimeout: "1 second", - }), + NodeHttpServer.layer(() => server, nodeListenOptions(opts, port)), Layer.succeed(ListenerServerService)( ListenerServerService.of({ closeAll: Effect.sync(() => { @@ -244,4 +237,9 @@ function serverLayer(opts: { port: number; hostname: string } | { socket: string ) } +function nodeListenOptions(opts: ListenOptions, port: number) { + if (opts.socket) return { path: opts.socket, gracefulShutdownTimeout: "1 second" as const } + return { port, host: opts.hostname, gracefulShutdownTimeout: "1 second" as const } +} + export * as Server from "./server" From 971fe35e81834e866600668b8aa4cce10517258a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:41:40 -0400 Subject: [PATCH 03/13] serve: keep socket listener option-driven --- packages/opencode/src/server/server.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2234722928a..5e32a33e93e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -88,11 +88,9 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) - const address = state.server.address - - if (address._tag === "UnixAddress") return yield* makeSocketListener(state, address.path) - if (opts.socket) return yield* unexpectedAddress(state) + if (opts.socket) return yield* makeSocketListener(state, opts.socket) + const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) url = listenerUrl @@ -150,7 +148,7 @@ function startListener(opts: ListenOptions, port: number) { function makeSocketListener(state: ListenerState, socket: string) { return Effect.gen(function* () { - const listenerUrl = makeURL("localhost") + const listenerUrl = makeURL("localhost", 0) url = listenerUrl return { @@ -163,17 +161,18 @@ function makeSocketListener(state: ListenerState, socket: string) { }) } -function unexpectedAddress(state: ListenerState) { +function tcpAddress(state: ListenerState) { return Effect.gen(function* () { + if (state.server.address._tag === "TcpAddress") return state.server.address yield* Scope.close(state.scope, Exit.void).pipe(Effect.ignore) return yield* Effect.die(new Error(`Unexpected HttpServer address tag: ${state.server.address._tag}`)) }) } -function makeURL(hostname: string, port?: number) { +function makeURL(hostname: string, port: number) { const result = new URL("http://localhost") result.hostname = hostname - if (port !== undefined) result.port = String(port) + result.port = String(port) return result } From a2008478f981264a4706bf7dd5463d274f477905 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:43:56 -0400 Subject: [PATCH 04/13] serve: type listener transports explicitly --- packages/opencode/src/cli/cmd/serve.ts | 10 ++-- packages/opencode/src/server/server.ts | 57 ++++++++++++++----- .../test/server/httpapi-listen.test.ts | 6 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 2955715f9f3..f662fa1ff0f 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -25,10 +25,12 @@ export const ServeCommand = effectCmd({ if (conflicts.length) yield* fail(`--socket cannot be used with ${conflicts.join(", ")}`) } const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => - Server.listen(args.socket ? { ...opts, socket: resolveSocketPath(args.socket) } : opts), - ) - if (server.socket) { + const server = args.socket + ? yield* Effect.promise(() => + Server.listen({ type: "socket", socket: resolveSocketPath(args.socket), cors: opts.cors }), + ) + : yield* Effect.promise(() => Server.listen(opts)) + if (server.type === "socket") { console.log(`opencode server listening on socket ${server.socket}`) yield* Effect.never } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5e32a33e93e..f7b1a68fe0e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -19,35 +19,55 @@ globalThis.AI_SDK_LOG_WARNINGS = false const log = Log.create({ service: "server" }) -export type Listener = { +export type TcpListener = { + type: "tcp" hostname: string port: number url: URL - socket?: string stop: (close?: boolean) => Promise } +export type SocketListener = { + type: "socket" + socket: string + url: URL + stop: (close?: boolean) => Promise +} + +export type Listener = TcpListener | SocketListener + type ServerApp = { fetch(request: Request): Response | Promise request(input: string | URL | Request, init?: RequestInit): Response | Promise } -type ListenOptions = CorsOptions & { +export type TcpListenOptions = CorsOptions & { + type?: "tcp" port: number hostname: string - socket?: string mdns?: boolean mdnsDomain?: string } + +export type SocketListenOptions = CorsOptions & { + type: "socket" + socket: string +} + +export type ListenOptions = TcpListenOptions | SocketListenOptions type ListenerState = { scope: Scope.Scope server: Context.Service.Shape http: ListenerServer websockets: WebSocketTracker.Interface } -type EffectListener = Omit & { +type EffectTcpListener = Omit & { + stop: (close?: boolean) => Effect.Effect +} +type EffectSocketListener = Omit & { stop: (close?: boolean) => Effect.Effect } +type EffectListener = EffectTcpListener | EffectSocketListener interface ListenerServer { readonly closeAll: Effect.Effect @@ -74,21 +94,32 @@ export async function openapi() { export let url: URL +export function listen(opts: TcpListenOptions): Promise +export function listen(opts: SocketListenOptions): Promise export async function listen(opts: ListenOptions): Promise { const listener = await Effect.runPromise(listenEffect(opts)) + const stop = (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined) + if (listener.type === "socket") { + return { + type: "socket" as const, + socket: listener.socket, + url: listener.url, + stop, + } + } return { + type: "tcp" as const, hostname: listener.hostname, port: listener.port, url: listener.url, - socket: listener.socket, - stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), + stop, } } const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) - if (opts.socket) return yield* makeSocketListener(state, opts.socket) + if (opts.type === "socket") return yield* makeSocketListener(state, opts.socket) const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) @@ -97,6 +128,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect>, label: string) { +function stop(listener: { stop(close?: boolean): Promise }, label: string) { return withTimeout(listener.stop(true), 10_000, label) } @@ -293,9 +293,9 @@ describe("HttpApi Server.listen", () => { process.platform === "win32" ? `\\\\.\\pipe\\opencode-test-${process.pid}-${Date.now()}` : path.join(tmp.path, "opencode.sock") - const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, socket }) + const listener = await Server.listen({ type: "socket", socket }) try { - expect(listener.port).toBe(0) + expect(listener.type).toBe("socket") expect(listener.socket).toBe(socket) const response = await requestSocketRoot(socket) From 25400f48df6e1693f5d9d76f56bc2e471857dd5c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:48:00 -0400 Subject: [PATCH 05/13] serve: keep resolved listen options together --- packages/opencode/src/server/server.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f7b1a68fe0e..2505c92f190 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -137,14 +137,14 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect startListener(opts, 0))) + return startListener({ ...opts, port: 4096 }).pipe(Effect.catch(() => startListener(opts))) } -function startListener(opts: ListenOptions, port: number) { +function startListener(opts: ListenOptions) { const scope = Scope.makeUnsafe() - return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe( + return Layer.buildWithMemoMap(listenerLayer(opts), Layer.makeMemoMapUnsafe(), scope).pipe( Effect.provide(HttpApiApp.context), Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), Effect.map( @@ -240,7 +239,7 @@ function forceClose(state: ListenerState) { return Effect.all([state.http.closeAll, state.websockets.closeAll], { concurrency: "unbounded", discard: true }) } -function serverLayer(opts: ListenOptions, port: number) { +function serverLayer(opts: ListenOptions) { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) @@ -255,7 +254,7 @@ function serverLayer(opts: ListenOptions, port: number) { }) as typeof server.close return Layer.mergeAll( - NodeHttpServer.layer(() => server, nodeListenOptions(opts, port)), + NodeHttpServer.layer(() => server, nodeListenOptions(opts)), Layer.succeed(ListenerServerService)( ListenerServerService.of({ closeAll: Effect.sync(() => { @@ -267,9 +266,9 @@ function serverLayer(opts: ListenOptions, port: number) { ) } -function nodeListenOptions(opts: ListenOptions, port: number) { +function nodeListenOptions(opts: ListenOptions) { if (opts.type === "socket") return { path: opts.socket, gracefulShutdownTimeout: "1 second" as const } - return { port, host: opts.hostname, gracefulShutdownTimeout: "1 second" as const } + return { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" as const } } export * as Server from "./server" From d1f44d4cd2ad846d318dfbfe1c87ae77a00028d2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:49:19 -0400 Subject: [PATCH 06/13] serve: require explicit tcp listener type --- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/network.ts | 2 +- packages/opencode/src/server/server.ts | 28 ++++++++----------- .../opencode/test/server/httpapi-cors.test.ts | 4 ++- .../test/server/httpapi-listen.test.ts | 4 +-- .../opencode/test/server/httpapi-mdns.test.ts | 6 ++-- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 31ead18cf84..170d9d28047 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -72,7 +72,7 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = await Server.listen(input) + server = await Server.listen({ type: "tcp", ...input }) return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 41f8184ef5d..a4a49640cef 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -58,5 +58,5 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, mdnsDomain, cors } + return { type: "tcp" as const, hostname, port, mdns, mdnsDomain, cors } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 2505c92f190..c6adb432ce1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -42,7 +42,7 @@ type ServerApp = { } export type TcpListenOptions = CorsOptions & { - type?: "tcp" + type: "tcp" port: number hostname: string mdns?: boolean @@ -119,7 +119,17 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { const state = yield* startWithPortFallback(opts) - if (opts.type === "socket") return yield* makeSocketListener(state, opts.socket) + if (opts.type === "socket") { + const listenerUrl = makeURL("localhost", 0) + url = listenerUrl + + return { + type: "socket" as const, + socket: opts.socket, + url: listenerUrl, + stop: yield* makeStop(state, Effect.void), + } + } const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) @@ -177,20 +187,6 @@ function startListener(opts: ListenOptions) { ) } -function makeSocketListener(state: ListenerState, socket: string) { - return Effect.gen(function* () { - const listenerUrl = makeURL("localhost", 0) - url = listenerUrl - - return { - type: "socket" as const, - socket, - url: listenerUrl, - stop: yield* makeStop(state, Effect.void), - } - }) -} - function tcpAddress(state: ListenerState) { return Effect.gen(function* () { if (state.server.address._tag === "TcpAddress") return state.server.address diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 4e9680c7ce4..266adb153ab 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -85,7 +85,9 @@ describe("HttpApi CORS", () => { it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease( - Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })), + Effect.promise(() => + Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }), + ), (listener) => Effect.promise(() => listener.stop(true)), ) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 60cd214fea4..f8b4e8baeea 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -37,7 +37,7 @@ async function startListener() { Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password process.env.OPENCODE_SERVER_USERNAME = auth.username - return Server.listen({ hostname: "127.0.0.1", port: 0 }) + return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 }) } async function startNoAuthListener() { @@ -45,7 +45,7 @@ async function startNoAuthListener() { Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD process.env.OPENCODE_SERVER_USERNAME = auth.username - return Server.listen({ hostname: "127.0.0.1", port: 0 }) + return Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0 }) } function authorization() { diff --git a/packages/opencode/test/server/httpapi-mdns.test.ts b/packages/opencode/test/server/httpapi-mdns.test.ts index 6b88a1f10bf..52e24fc8ae1 100644 --- a/packages/opencode/test/server/httpapi-mdns.test.ts +++ b/packages/opencode/test/server/httpapi-mdns.test.ts @@ -45,7 +45,7 @@ describe("HttpApi Server.listen mDNS", () => { test("skips publish for loopback hostnames", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "127.0.0.1", port: 0, mdns: true }) try { expect(events.filter((e) => e.kind === "publish")).toEqual([]) } finally { @@ -57,7 +57,7 @@ describe("HttpApi Server.listen mDNS", () => { test("publishes for non-loopback hostnames and unpublishes on stop", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true }) try { const published = events.filter((e) => e.kind === "publish") expect(published.length).toBe(1) @@ -73,7 +73,7 @@ describe("HttpApi Server.listen mDNS", () => { test("scope finalizer unpublishes even if stop() is not called for force-close", async () => { Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" Flag.OPENCODE_SERVER_USERNAME = "opencode" - const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + const listener = await Server.listen({ type: "tcp", hostname: "0.0.0.0", port: 0, mdns: true }) expect(events.filter((e) => e.kind === "publish").length).toBe(1) // Plain (graceful) stop without close=true should still unpublish. await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener") From cb45de6fd2e16953c1a2fa93a70085c8538210b4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:49:27 -0400 Subject: [PATCH 07/13] serve: inline node listen options --- packages/opencode/src/server/server.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c6adb432ce1..61bb8a596c1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -250,7 +250,10 @@ function serverLayer(opts: ListenOptions) { }) as typeof server.close return Layer.mergeAll( - NodeHttpServer.layer(() => server, nodeListenOptions(opts)), + NodeHttpServer.layer(() => server, { + ...(opts.type === "socket" ? { path: opts.socket } : { port: opts.port, host: opts.hostname }), + gracefulShutdownTimeout: "1 second", + }), Layer.succeed(ListenerServerService)( ListenerServerService.of({ closeAll: Effect.sync(() => { @@ -262,9 +265,4 @@ function serverLayer(opts: ListenOptions) { ) } -function nodeListenOptions(opts: ListenOptions) { - if (opts.type === "socket") return { path: opts.socket, gracefulShutdownTimeout: "1 second" as const } - return { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" as const } -} - export * as Server from "./server" From 3caba56bd9ce249ad7d182cd561d41b10a988aeb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 00:51:18 -0400 Subject: [PATCH 08/13] serve: fold mdns domain into option --- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/cli/network.ts | 4 ++-- packages/opencode/src/server/server.ts | 5 ++--- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 170d9d28047..b778c8f7cdf 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -72,7 +72,7 @@ export const rpc = { }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { if (server) await server.stop(true) - server = await Server.listen({ type: "tcp", ...input }) + server = await Server.listen({ type: "tcp", ...input, mdns: input.mdns ? true : undefined }) return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 384290c6ac3..13666cffc89 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -67,7 +67,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, + `${opts.mdns === true ? "opencode.local" : opts.mdns.domain}:${server.port}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index a4a49640cef..4441acf7ff8 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -47,7 +47,7 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) - const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : config?.server?.mdnsDomain const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet ? args.hostname @@ -58,5 +58,5 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { type: "tcp" as const, hostname, port, mdns, mdnsDomain, cors } + return { type: "tcp" as const, hostname, port, mdns: mdns ? (mdnsDomain ? { domain: mdnsDomain } : true) : undefined, cors } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 61bb8a596c1..0028fc99b71 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,8 +45,7 @@ export type TcpListenOptions = CorsOptions & { type: "tcp" port: number hostname: string - mdns?: boolean - mdnsDomain?: string + mdns?: true | { domain: string } } export type SocketListenOptions = CorsOptions & { @@ -208,7 +207,7 @@ function setupMdns(opts: TcpListenOptions, port: number, scope: Scope.Scope) { opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (publish) { const unpublish = yield* Effect.cached(Effect.sync(() => MDNS.unpublish())) - yield* Effect.sync(() => MDNS.publish(port, opts.mdnsDomain)) + yield* Effect.sync(() => MDNS.publish(port, opts.mdns === true ? undefined : opts.mdns.domain)) yield* Scope.addFinalizer(scope, unpublish) return unpublish } From 91d148d95fc3ec3f8971ad92eb5627b99e60125d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 01:00:02 -0400 Subject: [PATCH 09/13] serve: avoid fake socket listener port --- packages/opencode/src/server/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0028fc99b71..f17e095ef90 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -119,7 +119,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect Date: Sat, 16 May 2026 01:01:03 -0400 Subject: [PATCH 10/13] serve: drop socket listener url --- packages/opencode/src/server/server.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f17e095ef90..e832cf286fb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -30,7 +30,6 @@ export type TcpListener = { export type SocketListener = { type: "socket" socket: string - url: URL stop: (close?: boolean) => Promise } @@ -102,7 +101,6 @@ export async function listen(opts: ListenOptions): Promise { return { type: "socket" as const, socket: listener.socket, - url: listener.url, stop, } } @@ -119,13 +117,9 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect Date: Sat, 16 May 2026 01:01:53 -0400 Subject: [PATCH 11/13] serve: expose socket listener url --- packages/opencode/src/server/server.ts | 9 +++++++++ packages/opencode/test/server/httpapi-listen.test.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e832cf286fb..78649bdda8a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -30,6 +30,7 @@ export type TcpListener = { export type SocketListener = { type: "socket" socket: string + url: URL stop: (close?: boolean) => Promise } @@ -101,6 +102,7 @@ export async function listen(opts: ListenOptions): Promise { return { type: "socket" as const, socket: listener.socket, + url: listener.url, stop, } } @@ -117,9 +119,12 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect { try { expect(listener.type).toBe("socket") expect(listener.socket).toBe(socket) + expect(listener.url.href).toBe(`socket:${encodeURIComponent(socket)}`) const response = await requestSocketRoot(socket) expect(response.statusCode).toBe(200) From 342d02aefec206275e485d79353beac3284441e0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 16 May 2026 01:06:38 -0400 Subject: [PATCH 12/13] serve: inline listener url creation --- packages/opencode/src/server/server.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 78649bdda8a..3aabdb54d63 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -119,18 +119,18 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect Date: Sat, 16 May 2026 02:28:34 -0400 Subject: [PATCH 13/13] serve: early-return for socket, skip network options resolve --- packages/opencode/src/cli/cmd/serve.ts | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index f662fa1ff0f..b4c7c0a8336 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 { effectCmd, fail } from "../effect-cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" @@ -13,27 +13,21 @@ export const ServeCommand = effectCmd({ describe: "Unix socket path or Windows named pipe name/path to listen on", }), 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.") } if (args.socket) { - const conflicts = explicitNetworkConflicts() - if (conflicts.length) yield* fail(`--socket cannot be used with ${conflicts.join(", ")}`) - } - const opts = yield* resolveNetworkOptions(args) - const server = args.socket - ? yield* Effect.promise(() => - Server.listen({ type: "socket", socket: resolveSocketPath(args.socket), cors: opts.cors }), - ) - : yield* Effect.promise(() => Server.listen(opts)) - if (server.type === "socket") { + const server = yield* Effect.promise(() => + Server.listen({ type: "socket", socket: resolveSocketPath(args.socket) }), + ) console.log(`opencode server listening on socket ${server.socket}`) yield* Effect.never } + + const opts = yield* resolveNetworkOptions(args) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) yield* Effect.never @@ -51,8 +45,4 @@ function resolveSocketPath(input: string) { return `\\\\.\\pipe\\${name || "opencode"}` } -function explicitNetworkConflicts() { - return ["--port", "--hostname", "--mdns", "--mdns-domain"].filter((flag) => - process.argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`)), - ) -} +