feat(client): self-contained local service discovery and lifecycle

This commit is contained in:
Dax Raad 2026-07-03 01:19:43 -04:00
commit 1de3c6e4a6
27 changed files with 535 additions and 480 deletions

View file

@ -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"),

View file

@ -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<string, string>,
) {

View file

@ -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(

View file

@ -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<FileSystem.FileSystem | Global.Service>()
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()
}

View file

@ -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)

View file

@ -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) {

View file

@ -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) {

View file

@ -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}`)

View file

@ -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)
}),
)

View file

@ -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)
}),
)

View file

@ -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)
}),
)

View file

@ -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)
}),
)

View file

@ -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)
}),
)

View file

@ -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()
}),
)

View file

@ -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)
}),
)