diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 82d273c25de..de4d1a2b876 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -4,7 +4,6 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" import { ServiceConfig } from "../../services/service-config" -import type { Transport } from "@opencode-ai/client/effect" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: Transport, + transport: Service.Transport, input: readonly string[], params: Record, ) { diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 98b0c9b3c93..32e542e6347 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -3,7 +3,6 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Effect, Option } from "effect" import { Service } from "@opencode-ai/client/effect" -import type { Transport } from "@opencode-ai/client/effect" import { ServiceConfig } from "../../services/service-config" import { Standalone } from "../../services/standalone" import { Updater } from "../../services/updater" @@ -23,7 +22,7 @@ export default Runtime.handler(Commands, (input) => return { url: server, headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined, - } satisfies Transport + } satisfies Service.Transport } if (input.standalone) return yield* Standalone.transport() const options = yield* ServiceConfig.options() diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 80cf6cba9f1..d6b6938ee32 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -14,6 +14,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" +import { Service } from "@opencode-ai/client/effect" import { ServiceConfig } from "../../services/service-config" import { Updater } from "../../services/updater" import { randomBytes, randomUUID } from "crypto" @@ -63,8 +64,11 @@ export default Runtime.handler( // 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. -const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) }) -const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId)) +// Written and read through Service.Info so the file the server registers is +// provably the contract clients discover with. +const infoJson = Schema.fromJsonString(Service.Info) +const encodeInfo = Schema.encodeEffect(infoJson) +const decodeInfo = Schema.decodeUnknownEffect(infoJson) const register = Effect.fnUntraced(function* (address: HttpServer.Address) { const fs = yield* FileSystem.FileSystem @@ -73,20 +77,17 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address) { const secret = yield* ServiceConfig.password() const temp = file + "." + id + ".tmp" yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString( - temp, - JSON.stringify({ - id, - version: InstallationVersion, - url: HttpServer.formatAddress(address), - pid: process.pid, - password: secret, - }), - { mode: 0o600 }, - ) + const encoded = yield* encodeInfo({ + id, + version: InstallationVersion, + url: HttpServer.formatAddress(address), + pid: process.pid, + password: secret, + }) + yield* fs.writeFileString(temp, encoded, { mode: 0o600 }) yield* fs.rename(temp, file) const currentID = fs.readFileString(file).pipe( - Effect.flatMap(decodeRegistrationId), + Effect.flatMap(decodeInfo), Effect.map((info) => info.id), Effect.orElseSucceed(() => undefined), ) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 356472e3595..ecdd5bf19a0 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -5,7 +5,7 @@ import { Effect, FileSystem, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" -// The CLI's service configuration file, plus the ServiceOptions binding that +// The CLI's service configuration file, plus the Service.Options binding that // points the client package's service operations at this CLI: which // registration file (by channel), which version, and how to spawn opencode. diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 577a7be4936..a535309d422 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -5,11 +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/promise" -import type { Transport } from "@opencode-ai/client/effect" +import type { Service } from "@opencode-ai/client/effect" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { Args } from "@opencode-ai/tui/context/args" -export function runTui(transport: Transport, args: Args, discover?: () => Promise) { +export function runTui(transport: Service.Transport, args: Args, discover?: () => Promise) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 1a69a358cc6..23e6d55c748 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -2,7 +2,6 @@ // Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations. export * from "./generated/index" export { Service } from "./service.js" -export type { Transport, ServiceOptions } from "./service.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Credential } from "@opencode-ai/schema/credential" diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 93cbdd03fce..ca428f5aa62 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -16,7 +16,7 @@ export type Transport = { readonly headers?: RequestInit["headers"] } -export type ServiceOptions = { +export type Options = { // Absolute path to the service registration file. Defaults to // opencode/service.json in the XDG state directory. readonly file?: string @@ -30,21 +30,21 @@ export type ServiceOptions = { // Read-only lookup: registration file plus health check and version gate. // Never spawns; escalation to start() is the caller's policy. -export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - if (options.version !== undefined && registration.version !== options.version) return undefined - const found = yield* probe(registration) +export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { + const info = yield* read(options.file) + if (info === undefined) return undefined + if (options.version !== undefined && info.version !== options.version) return undefined + const found = yield* probe(info) return found?.transport }) // Idempotent ensure-running: reuses a healthy compatible server, replaces a // version-mismatched one, and otherwise spawns the service command detached. -export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) { +export const start = Effect.fn("service.start")(function* (options: Options = {}) { const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) - if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore) + if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] if (command === undefined) return yield* Effect.fail(new Error("Missing service command")) @@ -64,10 +64,10 @@ export const start = Effect.fn("service.start")(function* (options: ServiceOptio ) }) -export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) { +export const stop = Effect.fn("service.stop")(function* (options: Options = {}) { const fs = yield* FileSystem.FileSystem const existing = yield* find(options) - if (existing !== undefined) yield* kill(existing.registration, options) + if (existing !== undefined) yield* kill(existing.info, options) yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) }) @@ -80,18 +80,18 @@ function auth(password: string): RequestInit["headers"] { return { authorization: "Basic " + btoa("opencode:" + password) } } -const Registration = Schema.Struct({ +export const Info = Schema.Struct({ id: Schema.optional(Schema.String), version: Schema.optional(Schema.String), url: Schema.String, pid: Schema.Int.check(Schema.isGreaterThan(0)), password: Schema.optional(Schema.String), }) -type Registration = typeof Registration.Type +export type Info = typeof Info.Type -const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) +const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) -// A missing or corrupt file means no valid registration; callers treat both +// A missing or corrupt file means no valid info; callers treat both // the same (the registering server self-evicts, clients rediscover). const read = Effect.fnUntraced(function* (file?: string) { const fs = yield* FileSystem.FileSystem @@ -101,14 +101,14 @@ const read = Effect.fnUntraced(function* (file?: string) { }) type LocalService = { - readonly registration: Registration + readonly info: Info readonly transport: Transport } -const probe = Effect.fnUntraced(function* (registration: Registration) { - const headers = registration.password === undefined ? undefined : auth(registration.password) +const probe = Effect.fnUntraced(function* (info: Info) { + const headers = info.password === undefined ? undefined : auth(info.password) const healthy = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", registration.url), { + fetch(new URL("/api/health", info.url), { headers, signal: AbortSignal.timeout(2_000), }), @@ -117,15 +117,15 @@ const probe = Effect.fnUntraced(function* (registration: Registration) { Effect.orElseSucceed(() => false), ) if (!healthy) return undefined - return { registration, transport: { url: registration.url, headers } } satisfies LocalService + return { info, transport: { url: info.url, headers } } satisfies LocalService }) // Health-checked lookup without the version gate: lifecycle operations must be // able to see (and replace or stop) a server from a different version. -const find = Effect.fnUntraced(function* (options: ServiceOptions) { - const registration = yield* read(options.file) - if (registration === undefined) return undefined - return yield* probe(registration) +const find = Effect.fnUntraced(function* (options: Options) { + const info = yield* read(options.file) + if (info === undefined) return undefined + return yield* probe(info) }) // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. @@ -142,22 +142,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) { return yield* Effect.fail(new Error(`Server process ${pid} is still running`)) }) -function same(left: Registration, right: Registration) { +function same(left: Info, right: Info) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid } -const kill = Effect.fnUntraced(function* (info: Registration, options: ServiceOptions) { +const kill = Effect.fnUntraced(function* (info: Info, options: Options) { // 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 = yield* find(options) - if (current === undefined || !same(current.registration, info)) return + if (current === undefined || !same(current.info, info)) return yield* signal(info.pid, "SIGTERM") const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option) if (Option.isSome(done)) return const latest = yield* find(options) - if (latest === undefined || !same(latest.registration, info)) return + if (latest === undefined || !same(latest.info, info)) return yield* signal(info.pid, "SIGKILL") yield* stopped(info.pid).pipe(Effect.retry(poll)) }) diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index 61bdcdf888f..e9e848b160b 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -1,4 +1,3 @@ export * from "./generated/index" -export type { Transport } from "../effect/service.js" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type OpenCodeClient = ReturnType