diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index d61d21b4f2..b8101f4ae5 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -14,6 +14,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDescription("Run with a private server instead of the background service"), Flag.withDefault(false), ), + server: Flag.string("server").pipe( + Flag.withDescription("Connect to a server URL instead of the background service"), + Flag.optional, + ), continue: Flag.boolean("continue").pipe( Flag.withAlias("c"), Flag.withDescription("Continue the last session"), diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index cf00394cb9..2ed0adb917 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -2,7 +2,8 @@ import { EOL } from "node:os" import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Daemon } from "../../services/daemon" +import { Service } from "../../services/service" +import type { Transport } from "@opencode-ai/client/service" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -17,8 +18,7 @@ type OpenApi = { export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { - const daemon = yield* Daemon.Service - const transport = yield* daemon.transport() + const transport = yield* Service.connect() const params = Option.getOrElse(input.param, () => ({})) const request = yield* resolveRequest(transport, input.request, params) const headers = new Headers(transport.headers) @@ -58,7 +58,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: { url: string; headers: RequestInit["headers"] }, + transport: Transport, input: readonly string[], params: Record, ) { diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 3a0c20cb06..fd0e1500c4 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -1,14 +1,15 @@ import { EOL } from "os" import * as Effect from "effect/Effect" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.debug.commands.agents, Effect.fn("cli.debug.agents")(function* () { - const daemon = yield* Daemon.Service - const client = yield* daemon.client() + const transport = yield* Service.connect() + const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( JSON.stringify( diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 193899d56e..c48a383534 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,9 +1,12 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect, Option } from "effect" -import { Daemon } from "../../services/daemon" +import { Global } from "@opencode-ai/core/global" +import { Effect, FileSystem, Option } from "effect" +import { Service } from "../../services/service" import { Standalone } from "../../services/standalone" import { Updater } from "../../services/updater" +import { basicAuth } from "@opencode-ai/client/service" +import type { Transport } from "@opencode-ai/client/service" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -11,18 +14,32 @@ export default Runtime.handler(Commands, (input) => if (directory !== undefined) process.chdir(directory) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.forkScoped) - const daemon = yield* Daemon.Service - const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) + const server = Option.getOrUndefined(input.server) + if (server !== undefined && input.standalone) + return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) + const transport = yield* resolveTransport(server, input.standalone) const { runTui } = yield* Effect.promise(() => import("../../tui")) - yield* runTui( - transport, - { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, - input.standalone - ? undefined - : async () => { - await Effect.runPromise(daemon.stop()) - return Effect.runPromise(daemon.transport()) - }, - ) + // The TUI re-runs discover whenever its event stream drops. For an explicit + // --server or a standalone child the transport is fixed, so reconnects + // retry the same address; for the managed service discovery re-reads the + // registration and may start a replacement. + const context = yield* Effect.context() + const discover = + server !== undefined || input.standalone + ? () => Promise.resolve(transport) + : () => Effect.runPromise(Service.connect().pipe(Effect.provide(context))) + yield* runTui(transport, { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, discover) }), ) + +function resolveTransport(server: string | undefined, standalone: boolean) { + if (server !== undefined) { + const password = process.env["OPENCODE_SERVER_PASSWORD"] + return Effect.succeed({ + url: server, + headers: password ? basicAuth(password) : undefined, + } satisfies Transport) + } + if (standalone) return Standalone.transport() + return Service.connect() +} diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 2dc722076e..96affd21f8 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -1,9 +1,14 @@ import { EOL } from "node:os" import { Effect } from "effect" -import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { + createOpencodeClient, + type IntegrationAttemptStatus, + type IntegrationOAuthMethod, + type OpencodeClient, +} from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" import { resolveIntegration } from "./resolve" const location = { directory: process.cwd() } @@ -11,8 +16,8 @@ const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.auth, Effect.fn("cli.mcp.auth")(function* (input) { - const daemon = yield* Daemon.Service - const client = yield* daemon.client() + const transport = yield* Service.connect() + const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const integration = yield* resolveIntegration(client, input.name, location) if (!integration) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index 7866705ad1..e7e2dd3e38 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -1,15 +1,15 @@ import { EOL } from "node:os" import * as Effect from "effect/Effect" -import type { McpServer } from "@opencode-ai/sdk/v2/client" +import { createOpencodeClient, type McpServer } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.mcp.commands.list, Effect.fn("cli.mcp.list")(function* () { - const daemon = yield* Daemon.Service - const client = yield* daemon.client() + const transport = yield* Service.connect() + const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) if (servers.length === 0) { diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index 6c4296ac38..1fa1e482dc 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -1,8 +1,9 @@ import { EOL } from "node:os" import { Effect } from "effect" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" import { resolveIntegration } from "./resolve" const location = { directory: process.cwd() } @@ -10,8 +11,8 @@ const location = { directory: process.cwd() } export default Runtime.handler( Commands.commands.mcp.commands.logout, Effect.fn("cli.mcp.logout")(function* (input) { - const daemon = yield* Daemon.Service - const client = yield* daemon.client() + const transport = yield* Service.connect() + const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const integration = yield* resolveIntegration(client, input.name, location) if (!integration) { diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index fed803fa1e..bf406f2a89 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -13,7 +13,7 @@ import { ServerAuth } from "@opencode-ai/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Daemon } from "../../services/daemon" +import { Service } from "../../services/service" import { Updater } from "../../services/updater" import { randomBytes } from "crypto" @@ -23,12 +23,11 @@ export default Runtime.handler( if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - const daemon = yield* Daemon.Service const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD - const config = input.service ? yield* daemon.config() : {} + const config = input.service ? yield* Service.config() : {} const password = input.service - ? yield* daemon.password() + ? yield* Service.password() : standalonePassword || randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" @@ -44,7 +43,7 @@ export default Runtime.handler( headers: ServerAuth.headers({ password }), }).v2.health.get({}), ) - if (input.service) yield* daemon.register(address) + if (input.service) yield* Service.register(address) const url = HttpServer.formatAddress(address) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) diff --git a/packages/cli/src/commands/handlers/service/get.ts b/packages/cli/src/commands/handlers/service/get.ts index aaaebf14a1..545ef7994f 100644 --- a/packages/cli/src/commands/handlers/service/get.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -3,12 +3,11 @@ import { Option } from "effect" import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.get, Effect.fn("cli.service.get")(function* (input) { - const daemon = yield* Daemon.Service - process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL) + process.stdout.write((yield* Service.get(Option.getOrUndefined(input.key))) + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index d348987d16..767ff65a5b 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -2,13 +2,12 @@ import { EOL } from "os" import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.restart, Effect.fn("cli.service.restart")(function* () { - const daemon = yield* Daemon.Service - yield* daemon.stop() - process.stdout.write((yield* daemon.start()) + EOL) + yield* Service.stop() + process.stdout.write((yield* Service.start()).url + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts index d1181ef14a..39c0be6079 100644 --- a/packages/cli/src/commands/handlers/service/set.ts +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -1,11 +1,11 @@ import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.set, Effect.fn("cli.service.set")(function* (input) { - yield* (yield* Daemon.Service).set(input.key, input.value) + yield* Service.set(input.key, input.value) }), ) diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index 0d6fbaada9..58807c8fea 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -2,11 +2,11 @@ import { EOL } from "os" import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.start, Effect.fn("cli.service.start")(function* () { - process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL) + process.stdout.write((yield* Service.start()).url + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index 779d456c78..9fc27505f6 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -2,12 +2,12 @@ import { EOL } from "os" import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.status, Effect.fn("cli.service.status")(function* () { - const url = yield* (yield* Daemon.Service).status() - process.stdout.write((url ? url : "stopped") + EOL) + const found = yield* Service.discover() + process.stdout.write((found ? found.url : "stopped") + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts index 8da9b04cff..1336cdbe1a 100644 --- a/packages/cli/src/commands/handlers/service/stop.ts +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -1,11 +1,11 @@ import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.stop, Effect.fn("cli.service.stop")(function* () { - yield* (yield* Daemon.Service).stop() + yield* Service.stop() }), ) diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts index f16bbe32cc..83b5c2b172 100644 --- a/packages/cli/src/commands/handlers/service/unset.ts +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -1,11 +1,11 @@ import * as Effect from "effect/Effect" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../services/daemon" +import { Service } from "../../../services/service" export default Runtime.handler( Commands.commands.service.commands.unset, Effect.fn("cli.service.unset")(function* (input) { - yield* (yield* Daemon.Service).unset(input.key) + yield* Service.unset(input.key) }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 44ed78ea72..902c89d716 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -1,9 +1,9 @@ import * as Effect from "effect/Effect" import * as Command from "effect/unstable/cli/Command" import { Spec } from "./spec" -import { Daemon } from "../services/daemon" +import { Global } from "@opencode-ai/core/global" import { Updater } from "../services/updater" -import { Scope } from "effect" +import { FileSystem, Scope } from "effect" export type Input = Value extends Spec.Node @@ -12,11 +12,11 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = (input: unknown) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 792261fac3..071fd37e1a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,6 @@ import * as Effect from "effect/Effect" import { Layer, Logger, References } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" -import { Daemon } from "./services/daemon" import { Logging } from "@opencode-ai/core/observability/logging" import { Updater } from "./services/updater" import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" @@ -50,7 +49,6 @@ const Handlers = Runtime.handlers(Commands, { Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe( Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), Effect.annotateLogs({ role: "cli" }), - Effect.provide(Daemon.layer), Effect.provide(Updater.layer), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(LoggingLayer), diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts deleted file mode 100644 index 1c91bcb7f7..0000000000 --- a/packages/cli/src/services/daemon.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { Global } from "@opencode-ai/core/global" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { ServerAuth } from "@opencode-ai/server/auth" -import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" -import { HttpServer } from "effect/unstable/http" -import { randomBytes, randomUUID } from "crypto" -import { spawn } from "node:child_process" -import path from "path" - -export interface Interface { - readonly client: () => Effect.Effect, unknown> - readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown> - readonly start: () => Effect.Effect - readonly status: () => Effect.Effect - readonly stop: () => Effect.Effect - readonly password: (value?: string) => Effect.Effect - readonly config: () => Effect.Effect - readonly get: (key?: string) => Effect.Effect - readonly set: (key: string, value: string) => Effect.Effect - readonly unset: (key: string) => Effect.Effect - readonly register: (address: HttpServer.Address) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/cli/Daemon") {} - -const Registration = Schema.Struct({ - id: Schema.optional(Schema.String), - version: Schema.optional(Schema.String), - url: Schema.String, - pid: Schema.Int.check(Schema.isGreaterThan(0)), -}) -type Registration = typeof Registration.Type - -const ServiceConfig = Schema.Struct({ - hostname: Schema.optional(Schema.String), - port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), - password: Schema.optional(Schema.String), - autostart: Schema.optional(Schema.Boolean), -}) -export type ServiceConfig = typeof ServiceConfig.Type - -const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const -type ServiceConfigKey = (typeof serviceConfigKeys)[number] - -function serviceConfigKey(key: string): ServiceConfigKey { - if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey - throw new Error(`Unknown service config key: ${key}`) -} - -function sameRegistration(left: Registration, right: Registration) { - return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const global = yield* Global.Service - const directory = global.state - const filename = InstallationChannel === "local" ? "service-local.json" : "service.json" - const file = path.join(directory, filename) - const configFile = path.join(global.config, filename) - const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) - const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) - - const config = Effect.fn("cli.daemon.config")(function* () { - return yield* fs.readFileString(configFile).pipe( - Effect.flatMap(decodeServiceConfig), - Effect.catch(() => Effect.succeed({} as ServiceConfig)), - ) - }) - - const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) { - const temp = configFile + ".tmp" - yield* fs.makeDirectory(path.dirname(configFile), { recursive: true }) - yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }) - yield* fs.rename(temp, configFile) - }) - - const password = Effect.fn("cli.daemon.password")(function* (value?: string) { - const existing = yield* config() - if (value === undefined && existing.password) return existing.password - const next = value ?? randomBytes(32).toString("base64url") - - // Keep one private credential across server restarts so discovered clients - // can reconnect without exposing a password flag or environment variable. - yield* writeConfig({ ...existing, password: next }) - return next - }) - - const get = Effect.fn("cli.daemon.get")(function* (key?: string) { - if (key === undefined) { - const { password: _password, ...safe } = yield* config() - return JSON.stringify(safe, null, 2) - } - switch (serviceConfigKey(key)) { - case "hostname": { - return (yield* config()).hostname ?? "" - } - case "port": { - const port = (yield* config()).port - return port === undefined ? "" : String(port) - } - case "password": { - return yield* password() - } - case "autostart": { - const autostart = (yield* config()).autostart - return autostart === undefined ? "" : String(autostart) - } - } - }) - - const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) { - switch (serviceConfigKey(key)) { - case "hostname": { - yield* stop() - yield* writeConfig({ ...(yield* config()), hostname: value }) - return - } - case "port": { - const port = Number(value) - if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535") - yield* stop() - yield* writeConfig({ ...(yield* config()), port }) - return - } - case "password": { - yield* stop() - yield* password(value) - return - } - case "autostart": { - if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false") - yield* writeConfig({ ...(yield* config()), autostart: value === "true" }) - return - } - } - }) - - const unset = Effect.fn("cli.daemon.unset")(function* (key: string) { - switch (serviceConfigKey(key)) { - case "hostname": { - yield* stop() - const { hostname: _hostname, ...next } = yield* config() - yield* writeConfig(next) - return - } - case "port": { - yield* stop() - const { port: _port, ...next } = yield* config() - yield* writeConfig(next) - return - } - case "password": { - yield* stop() - const { password: _password, ...next } = yield* config() - yield* writeConfig(next) - return - } - case "autostart": { - const { autostart: _autostart, ...next } = yield* config() - yield* writeConfig(next) - return - } - } - }) - - const registration = Effect.fnUntraced(function* () { - return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration)) - }) - - const createClient = Effect.fnUntraced(function* (url: string) { - return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) }) - }) - - const healthy = Effect.fnUntraced(function* () { - const info = yield* registration() - const client = yield* createClient(info.url) - const response = yield* Effect.tryPromise(() => client.v2.health.get({ signal: AbortSignal.timeout(2_000) })) - if (response.data?.healthy === true) return info - return yield* Effect.fail(new Error("Registered server is not healthy")) - }) - - const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) { - const url = serviceURL(input) - const headers = ServerAuth.headers({ password: input.password }) - const response = yield* Effect.tryPromise(() => - createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }), - ) - if (response.data?.healthy === true) return { url, headers } - return yield* Effect.fail(new Error(`Server is not healthy: ${url}`)) - }) - - const compatible = Effect.fnUntraced(function* () { - const info = yield* healthy() - if (info.version === InstallationVersion) return info - return yield* Effect.fail(new Error("Registered server version does not match the client")) - }) - - const signal = (pid: number, signal: NodeJS.Signals) => - Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore) - - const awaitStopped = Effect.fnUntraced(function* (pid: number) { - const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe( - Effect.orElseSucceed(() => false), - ) - if (!running) return true - return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) - }) - - const stopProcess = Effect.fnUntraced(function* (info: Registration) { - const current = yield* healthy().pipe(Effect.option) - if (Option.isNone(current) || !sameRegistration(current.value, info)) return - - yield* signal(info.pid, "SIGTERM") - const stopped = yield* awaitStopped(info.pid).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - Effect.option, - ) - if (Option.isSome(stopped)) return - - const latest = yield* healthy().pipe(Effect.option) - if (Option.isNone(latest) || !sameRegistration(latest.value, info)) return - yield* signal(info.pid, "SIGKILL") - yield* awaitStopped(info.pid).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - ) - }) - - const start = Effect.fn("cli.daemon.start")(function* () { - const existing = yield* healthy().pipe(Effect.option) - const found = Option.getOrUndefined(existing) - const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" - if (found?.version === InstallationVersion) return found.url - if (found) yield* stopProcess(found).pipe(Effect.ignore) - - const entrypoint = compiled ? undefined : process.argv[1] - if (!compiled && entrypoint === undefined) - return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) - yield* Effect.try({ - try: () => { - spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], { - detached: true, - stdio: "ignore", - }).unref() - }, - catch: (cause) => new Error("Failed to start server", { cause }), - }) - - return yield* compatible().pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))), - Effect.map((info) => info.url), - Effect.mapError(() => new Error("Failed to start server")), - ) - }) - - const transport = Effect.fn("cli.daemon.transport")(function* () { - const current = yield* config() - if (current.autostart === false) return yield* remoteTransport(current) - return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) } - }) - - const client = Effect.fn("cli.daemon.client")(function* () { - const connection = yield* transport() - return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers }) - }) - - const status = Effect.fn("cli.daemon.status")(function* () { - const existing = yield* healthy().pipe(Effect.option) - const found = Option.getOrUndefined(existing) - if (found?.version === InstallationVersion) return found.url - if (found) return undefined - yield* fs.remove(file).pipe(Effect.ignore) - return undefined - }) - - const stop = Effect.fn("cli.daemon.stop")(function* () { - const existing = yield* healthy().pipe(Effect.option) - // A stale registration may point at a PID that has since been reused by - // another process. Only signal the PID after authenticating the server. - if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore) - yield* stopProcess(existing.value) - yield* fs.remove(file).pipe(Effect.ignore) - }) - - const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) { - const id = randomUUID() - const temp = file + "." + id + ".tmp" - yield* fs.makeDirectory(directory, { recursive: true }) - yield* fs.writeFileString( - temp, - JSON.stringify({ id, version: InstallationVersion, url: HttpServer.formatAddress(address), pid: process.pid }), - { mode: 0o600 }, - ) - yield* fs.rename(temp, file) - yield* registration().pipe( - Effect.flatMap((info) => (info.id === id ? Effect.void : signal(process.pid, "SIGTERM"))), - Effect.catch(() => signal(process.pid, "SIGTERM")), - Effect.repeat(Schedule.spaced("10 seconds")), - Effect.forkScoped, - ) - yield* Effect.addFinalizer(() => - registration().pipe( - Effect.flatMap((info) => (info.id === id ? fs.remove(file) : Effect.void)), - Effect.ignore, - ), - ) - }) - - return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register }) - }), -) - -function serviceURL(config: ServiceConfig) { - const hostname = config.hostname ?? "127.0.0.1" - const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`) - result.port = String(config.port ?? 4096) - return result.toString() -} - -export * as Daemon from "./daemon" diff --git a/packages/cli/src/services/service.ts b/packages/cli/src/services/service.ts new file mode 100644 index 0000000000..e7801fd134 --- /dev/null +++ b/packages/cli/src/services/service.ts @@ -0,0 +1,203 @@ +import { Global } from "@opencode-ai/core/global" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { ServiceEffect } from "@opencode-ai/client/service/effect" +import { Effect, FileSystem, Schedule, Schema } from "effect" +import { HttpServer } from "effect/unstable/http" +import { randomBytes, randomUUID } from "crypto" +import path from "path" + +// Binds the client package's service operations to this CLI: which +// registration file (by channel), which version, and how to spawn opencode. +// Also owns the service config file and the server-side registration write. + +const ServiceConfig = Schema.Struct({ + hostname: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), + password: Schema.optional(Schema.String), +}) +export type ServiceConfig = typeof ServiceConfig.Type + +const serviceConfigKeys = ["hostname", "port", "password"] as const +type ServiceConfigKey = (typeof serviceConfigKeys)[number] + +const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) + +function serviceConfigKey(key: string): ServiceConfigKey { + if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey + throw new Error(`Unknown service config key: ${key}`) +} + +const env = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const global = yield* Global.Service + const filename = InstallationChannel === "local" ? "service-local.json" : "service.json" + return { + fs, + stateDir: global.state, + file: path.join(global.state, filename), + configFile: path.join(global.config, filename), + } +}) + +const options = Effect.fnUntraced(function* () { + const { file } = yield* env + const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" + const entrypoint = compiled ? undefined : process.argv[1] + if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) + return { + file, + version: InstallationVersion, + command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"], + } +}) + +export const discover = Effect.fn("cli.service.discover")(function* () { + const found = yield* ServiceEffect.discover(yield* options()) + return found?.transport +}) + +export const start = Effect.fn("cli.service.start")(function* () { + return yield* ServiceEffect.start(yield* options()) +}) + +export const connect = Effect.fn("cli.service.connect")(function* () { + return yield* ServiceEffect.connect(yield* options()) +}) + +export const stop = Effect.fn("cli.service.stop")(function* () { + return yield* ServiceEffect.stop(yield* options()) +}) + +export const config = Effect.fn("cli.service.config")(function* () { + const { fs, configFile } = yield* env + return yield* fs.readFileString(configFile).pipe( + Effect.flatMap(decodeServiceConfig), + Effect.catch(() => Effect.succeed({} as ServiceConfig)), + ) +}) + +const writeConfig = Effect.fn("cli.service.writeConfig")(function* (value: ServiceConfig) { + const { fs, configFile } = yield* env + const temp = configFile + ".tmp" + yield* fs.makeDirectory(path.dirname(configFile), { recursive: true }) + yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, configFile) +}) + +export const password = Effect.fn("cli.service.password")(function* (value?: string) { + const existing = yield* config() + if (value === undefined && existing.password) return existing.password + const next = value ?? randomBytes(32).toString("base64url") + + // Keep one private credential across server restarts so discovered clients + // can reconnect without exposing a password flag or environment variable. + yield* writeConfig({ ...existing, password: next }) + return next +}) + +export const get = Effect.fn("cli.service.get")(function* (key?: string) { + if (key === undefined) { + const { password: _password, ...safe } = yield* config() + return JSON.stringify(safe, null, 2) + } + switch (serviceConfigKey(key)) { + case "hostname": { + return (yield* config()).hostname ?? "" + } + case "port": { + const port = (yield* config()).port + return port === undefined ? "" : String(port) + } + case "password": { + return yield* password() + } + } +}) + +export const set = Effect.fn("cli.service.set")(function* (key: string, value: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + yield* writeConfig({ ...(yield* config()), hostname: value }) + return + } + case "port": { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535") + yield* stop() + yield* writeConfig({ ...(yield* config()), port }) + return + } + case "password": { + yield* stop() + yield* password(value) + return + } + } +}) + +export const unset = Effect.fn("cli.service.unset")(function* (key: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + const { hostname: _hostname, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "port": { + yield* stop() + const { port: _port, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "password": { + yield* stop() + const { password: _password, ...next } = yield* config() + yield* writeConfig(next) + return + } + } +}) + +// Server-side half of the registration protocol, run by `serve --service` at +// boot. The registration embeds the password so the file alone is enough for +// any client to discover and authenticate. service.json arbitrates ownership +// after concurrent starts; it is not a startup lock: the atomic rename elects +// the latest writer, the watcher self-evicts losers, and the finalizer +// id-guard keeps an exiting server from deleting its successor's registration. +export const register = Effect.fn("cli.service.register")(function* (address: HttpServer.Address) { + const { fs, stateDir, file } = yield* env + const id = randomUUID() + const secret = yield* password() + const temp = file + "." + id + ".tmp" + yield* fs.makeDirectory(stateDir, { recursive: true }) + yield* fs.writeFileString( + temp, + JSON.stringify({ + id, + version: InstallationVersion, + url: HttpServer.formatAddress(address), + pid: process.pid, + password: secret, + }), + { mode: 0o600 }, + ) + yield* fs.rename(temp, file) + yield* ServiceEffect.readRegistration(file).pipe( + Effect.flatMap((info) => + info?.id === id + ? Effect.void + : Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore), + ), + Effect.repeat(Schedule.spaced("10 seconds")), + Effect.forkScoped, + ) + yield* Effect.addFinalizer(() => + ServiceEffect.readRegistration(file).pipe( + Effect.flatMap((info) => (info?.id === id ? fs.remove(file) : Effect.void)), + Effect.ignore, + ), + ) +}) + +export * as Service from "./service" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 43c9fa3086..234f69d729 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -5,12 +5,11 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" import { OpenCode } from "@opencode-ai/client" +import type { Transport } from "@opencode-ai/client/service" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Args } from "@opencode-ai/tui/context/args" -type Transport = { url: string; headers: RequestInit["headers"] } - -export function runTui(transport: Transport, args: Args, reload?: () => Promise) { +export function runTui(transport: Transport, args: Args, discover?: () => Promise) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { @@ -25,9 +24,9 @@ export function runTui(transport: Transport, args: Args, reload?: () => Promise< return yield* run({ client: createOpencodeClient({ ...options, directory }), api, - reload: reload + discover: discover ? async () => { - const next = await reload() + const next = await discover() return { client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }), api: OpenCode.make({ baseUrl: next.url, headers: next.headers }), diff --git a/packages/cli/test/daemon.test.ts b/packages/cli/test/service.test.ts similarity index 77% rename from packages/cli/test/daemon.test.ts rename to packages/cli/test/service.test.ts index 138544fdf4..6a4330ac76 100644 --- a/packages/cli/test/daemon.test.ts +++ b/packages/cli/test/service.test.ts @@ -5,23 +5,19 @@ import { Effect } from "effect" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -import { Daemon } from "../src/services/daemon" +import { Service } from "../src/services/service" test("local channel stores service config with the local service filename", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-")) + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { await Effect.runPromise( - Effect.gen(function* () { - const daemon = yield* Daemon.Service - yield* daemon.set("autostart", "false") - }).pipe( - Effect.provide(Daemon.layer), + Service.set("hostname", "127.0.0.2").pipe( Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })), Effect.provide(NodeFileSystem.layer), ), ) expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({ - autostart: false, + hostname: "127.0.0.2", }) expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false) } finally { diff --git a/packages/client/package.json b/packages/client/package.json index 4f2445ca9d..0b93fa532a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,7 +6,9 @@ "license": "MIT", "exports": { ".": "./src/index.ts", - "./effect": "./src/effect.ts" + "./effect": "./src/effect.ts", + "./service": "./src/service/index.ts", + "./service/effect": "./src/service/effect.ts" }, "scripts": { "generate": "bun run script/build.ts", diff --git a/packages/client/src/service/effect.ts b/packages/client/src/service/effect.ts new file mode 100644 index 0000000000..8f58fc786a --- /dev/null +++ b/packages/client/src/service/effect.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import * as service from "./index.js" + +export type { Transport, Discover, Registration, LocalService, ServiceOptions } from "./index.js" + +export { basicAuth } from "./index.js" + +export const readRegistration = (file?: string) => Effect.promise(() => service.readRegistration(file)) +export const discover = (options?: service.ServiceOptions) => Effect.promise(() => service.discover(options)) +export const stop = (options?: service.ServiceOptions) => Effect.promise(() => service.stop(options)) +export const start = (options?: service.ServiceOptions) => + Effect.tryPromise({ + try: () => service.start(options), + catch: (cause) => new Error("Failed to start server", { cause }), + }) +export const connect = (options?: service.ServiceOptions) => + Effect.tryPromise({ + try: () => service.connect(options), + catch: (cause) => new Error("Failed to connect to server", { cause }), + }) + +export * as ServiceEffect from "./effect.js" diff --git a/packages/client/src/service/index.ts b/packages/client/src/service/index.ts new file mode 100644 index 0000000000..2222dd3905 --- /dev/null +++ b/packages/client/src/service/index.ts @@ -0,0 +1,172 @@ +import { spawn } from "node:child_process" +import { readFile, rm } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" + +// Everything a client needs to connect to an opencode server and to find or +// start the local background service. +// +// The service daemon advertises itself through a registration file in the +// user's state directory: url, pid, version, and the private password, with +// 0600 permissions. That file is the complete discovery contract — reading it +// is all a client needs to connect. The daemon's own configuration (port, +// persisted password) is CLI-owned and never read here. + +export type Transport = { + readonly url: string + readonly headers?: RequestInit["headers"] +} + +export type Discover = () => Promise + +export function basicAuth(password: string): RequestInit["headers"] { + return { authorization: "Basic " + btoa("opencode:" + password) } +} + +export type Registration = { + readonly id?: string + readonly version?: string + readonly url: string + readonly pid: number + readonly password?: string +} + +export type LocalService = { + readonly registration: Registration + readonly transport: Transport +} + +export type ServiceOptions = { + // Absolute path to the service registration file. Defaults to + // opencode/service.json in the XDG state directory. + readonly file?: string + // When set, discovery only returns a server reporting this exact version, + // and start() replaces a healthy server whose version differs. + readonly version?: string + // Argv used to spawn the service. Defaults to ["opencode", "serve", + // "--service"] resolved from PATH. + readonly command?: ReadonlyArray + readonly timeout?: number +} + +export function defaultRegistrationFile(): string { + const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state") + return join(state, "opencode", "service.json") +} + +export async function readRegistration(file?: string): Promise { + const text = await readFile(file ?? defaultRegistrationFile(), "utf8").catch(() => undefined) + if (text === undefined) return undefined + const value: unknown = JSON.parse(text) + if (typeof value !== "object" || value === null) return undefined + const record = value as Record + if (typeof record.url !== "string") return undefined + if (typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0) return undefined + return { + id: typeof record.id === "string" ? record.id : undefined, + version: typeof record.version === "string" ? record.version : undefined, + url: record.url, + pid: record.pid, + password: typeof record.password === "string" ? record.password : undefined, + } +} + +// Read-only lookup: registration file plus health check and version gate. +// Never spawns; escalation to start() is the caller's policy. +export async function discover(options: ServiceOptions = {}): Promise { + const registration = await readRegistration(options.file).catch(() => undefined) + if (registration === undefined) return undefined + if (options.version !== undefined && registration.version !== options.version) return undefined + return await probe(registration, options) +} + +async function probe(registration: Registration, options: ServiceOptions): Promise { + const headers = registration.password === undefined ? undefined : basicAuth(registration.password) + const healthy = await fetch(new URL("/api/health", registration.url), { + headers, + signal: AbortSignal.timeout(options.timeout ?? 2_000), + }) + .then((response) => response.ok) + .catch(() => false) + if (!healthy) return undefined + return { registration, transport: { url: registration.url, headers } } +} + +// Health-checked lookup without the version gate: lifecycle operations must be +// able to see (and replace or stop) a server from a different version. +async function anyService(options: ServiceOptions): Promise { + const registration = await readRegistration(options.file).catch(() => undefined) + if (registration === undefined) return undefined + return await probe(registration, options) +} + +function signal(pid: number, name: "SIGTERM" | "SIGKILL" | 0): boolean { + try { + process.kill(pid, name) + return true + } catch { + return false + } +} + +async function awaitStopped(pid: number, timeout: number): Promise { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + if (!signal(pid, 0)) return true + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return !signal(pid, 0) +} + +function sameRegistration(left: Registration, right: Registration) { + return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid +} + +async function stopProcess(info: Registration, options: ServiceOptions): Promise { + // A stale registration may point at a PID that has since been reused by + // another process. Only signal the PID after authenticating the server. + const current = await anyService(options) + if (current === undefined || !sameRegistration(current.registration, info)) return + + signal(info.pid, "SIGTERM") + if (await awaitStopped(info.pid, 5_000)) return + + const latest = await anyService(options) + if (latest === undefined || !sameRegistration(latest.registration, info)) return + signal(info.pid, "SIGKILL") + await awaitStopped(info.pid, 5_000) +} + +export async function stop(options: ServiceOptions = {}): Promise { + const existing = await anyService(options) + if (existing !== undefined) await stopProcess(existing.registration, options) + await rm(options.file ?? defaultRegistrationFile(), { force: true }).catch(() => undefined) +} + +// Idempotent ensure-running: reuses a healthy compatible server, replaces a +// version-mismatched one, and otherwise spawns the service command detached. +export async function start(options: ServiceOptions = {}): Promise { + const compatible = await discover(options) + if (compatible !== undefined) return compatible.transport + const mismatched = await anyService(options) + if (mismatched !== undefined) await stopProcess(mismatched.registration, options).catch(() => undefined) + + const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] + if (command === undefined) throw new Error("Missing service command") + spawn(command, args, { detached: true, stdio: "ignore" }).unref() + + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const found = await discover(options) + if (found !== undefined) return found.transport + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error("Failed to start server") +} + +// Default connection policy for the local service: discover, else start. +export async function connect(options: ServiceOptions = {}): Promise { + const found = await discover(options) + if (found !== undefined) return found.transport + return await start(options) +} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index fa68adb8ad..7c874ddf8d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -138,7 +138,7 @@ const appBindingCommands = [ export type TuiInput = { client: OpencodeClient api: OpenCodeClient - reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -301,7 +301,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -374,7 +374,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() - const reload = sdk.reload const toast = useToast() const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState @@ -801,33 +800,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "System", }, - ...(reload - ? [ - { - name: "server.reload", - title: "Reload server", - slashName: "reload", - slashAliases: ["restart"], - run: async () => { - dialog.clear() - toast.show({ - variant: "info", - message: "Reloading server...", - duration: 30000, - }) - await reload() - .then(() => - toast.show({ - variant: "success", - message: "Server reloaded", - }), - ) - .catch(toast.error) - }, - category: "System", - }, - ] - : []), { name: "theme.switch", title: "Switch theme", diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index f8297dd80b..390eb8aa4a 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -15,7 +15,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ init: (props: { client: OpencodeClient api: OpenCodeClient - reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> }) => { const abort = new AbortController() let client = props.client @@ -32,12 +32,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ connectedOnce: false, }) let stream: AbortController | undefined - let pending: Promise | undefined function start() { stream?.abort() const controller = new AbortController() - const current = client let connected!: () => void const ready = new Promise((resolve) => { connected = resolve @@ -54,7 +52,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ ) controller.signal.addEventListener("abort", cancel, { once: true }) const error = await (async () => { - const response = await current.v2.event.subscribe({ + const response = await client.v2.event.subscribe({ signal: connection.signal, sseMaxRetryAttempts: 0, throwOnError: true, @@ -86,6 +84,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ }) if (abort.signal.aborted || controller.signal.aborted) return attempt += 1 + // Re-resolve the transport before retrying: the server may have + // moved (service restarted on a new port) or need starting. Static + // transports (--server, standalone) resolve to the same address. + if (props.discover) { + const next = await props.discover().catch(() => undefined) + if (abort.signal.aborted || controller.signal.aborted) return + if (next) { + client = next.client + api = next.api + } + } setConnection({ status: "connecting", attempt, @@ -97,23 +106,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return ready } - const reload = props.reload - ? () => { - if (pending) return pending - pending = Promise.resolve() - .then(props.reload) - .then(async (next) => { - client = next.client - api = next.api - if (!abort.signal.aborted) await start() - }) - .finally(() => { - pending = undefined - }) - return pending - } - : undefined - onMount(() => void start()) onCleanup(() => { abort.abort() @@ -146,7 +138,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ return connection.connectedOnce }, }, - reload, } }, }) diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index ccf5be2fcb..dd87100714 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -47,7 +47,7 @@ function update(version: string): V2Event { } } -async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { +async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -61,7 +61,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC const app = await testRender(() => ( - + { @@ -79,7 +79,7 @@ async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenC )) await ready - return { app, emit: events.emit, project, sdk, seen, workspaces } + return { app, events, emit: events.emit, project, sdk, seen, workspaces } } function Probe(props: { @@ -148,24 +148,25 @@ describe("useEvent", () => { } }) - test("reloads the host and reconnects the event stream", async () => { + test("rediscovers the server after the event stream drops", async () => { let calls = 0 - const events = createEventStream() - const replacementCalls = createFetch(undefined, events) + const replacementEvents = createEventStream() + const replacementCalls = createFetch(undefined, replacementEvents) const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } - const { app, sdk, seen } = await mount(async () => { + const { app, events, sdk, seen } = await mount(async () => { calls += 1 return replacement }) try { await wait(() => sdk.connection.status() === "connected") - await sdk.reload?.() - await wait(() => sdk.connection.status() === "connected") - events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" })) - await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded")) + // Discovery only runs when the stream is down, never while connected. + expect(calls).toBe(0) + events.disconnect() + await wait(() => sdk.connection.status() === "connected" && calls > 0) + replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" })) + await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered")) - expect(calls).toBe(1) expect(sdk.client).toBe(replacement.client) expect(sdk.api).toBe(replacement.api) } finally { @@ -173,27 +174,24 @@ describe("useEvent", () => { } }) - test("keeps the current event stream alive while the host reload is pending", async () => { - let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void - const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => { - complete = resolve + test("keeps the current client when discovery fails", async () => { + let calls = 0 + const { app, events, sdk, seen } = await mount(async () => { + calls += 1 + throw new Error("no server") }) - const replacementEvents = createEventStream() - const replacementCalls = createFetch(undefined, replacementEvents) - const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } - const { app, emit, sdk, seen } = await mount(() => pending) try { await wait(() => sdk.connection.status() === "connected") - const reload = sdk.reload?.() - emit(event(vcs("during-reload"), { directory: "/tmp/reload" })) - await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload")) + const original = sdk.client + events.disconnect() + // Discovery rejects; the loop retries against the last known transport, + // which succeeds once the fixture accepts the reconnect. + await wait(() => calls > 0 && sdk.connection.status() === "connected") + events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" })) + await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "recovered")) - expect(sdk.connection.status()).toBe("connected") - complete(replacement) - await reload - expect(sdk.client).toBe(replacement.client) - expect(sdk.api).toBe(replacement.api) + expect(sdk.client).toBe(original) } finally { app.renderer.destroy() }