diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 76f6276af5da..18b16c72817f 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/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7230dae16ae4..5914fd7ada83 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)) @@ -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/cli/server-discovery.ts b/packages/opencode/src/cli/server-discovery.ts new file mode 100644 index 000000000000..8ba728939166 --- /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 +}