diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index c0f62b3ca07..1875e60891a 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 { 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) { - const { Server } = yield* Effect.promise(() => import("../../server/server")) - 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* () { + const { Server } = yield* Effect.promise(() => import("../../server/server")) + 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.ts b/packages/opencode/src/cli/cmd/tui.ts index 68941e976ac..e87c433ba24 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -8,6 +8,8 @@ import { errorMessage } from "@opencode-ai/tui/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 "@opencode-ai/tui/context/sdk" import { writeHeapSnapshot } from "v8" @@ -153,16 +155,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), } @@ -172,6 +184,7 @@ export const TuiThreadCommand = cmd({ sessionID: args.session, directory: cwd, fetch: transport.fetch, + headers: transport.headers, }) } catch (error) { UI.error(errorMessage(error)) @@ -199,6 +212,7 @@ export const TuiThreadCommand = cmd({ pluginHost: createLegacyTuiPluginHost(), 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 00000000000..297ab5af9a6 --- /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 { FSUtil } from "@opencode-ai/core/fs-util" +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* FSUtil.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(FSUtil.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 a672d2acae5..6deb0b704b6 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`] = ` diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 6c8fabdbb3a..cf7540a310c 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -3,12 +3,14 @@ import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useSync } from "../context/sync" +import { useSDK } from "../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