import { Effect, FileSystem, Option, Schedule, Schema } from "effect" import { spawn } from "node:child_process" import { homedir } from "node:os" import { join } from "node:path" // Find, start, and stop the local opencode 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 Options = { // 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 } // 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: Options = {}) { return (yield* discoverLocal(options))?.transport }) const discoverLocal = Effect.fnUntraced(function* (options: Options) { const info = yield* read(options.file) if (info === undefined) return undefined if (options.version !== undefined && info.version !== options.version) return undefined return yield* probe(info, options.version) }) // 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: Options = {}) { const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) 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")) const child = yield* Effect.try({ try: () => { const child = spawn(command, args, { detached: true, stdio: "ignore" }) child.unref() return child }, catch: (cause) => new Error("Failed to start server", { cause }), }) return yield* discoverLocal(options).pipe( Effect.flatMap((found) => found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found), ), Effect.retry(poll), Effect.tap((found) => found.info.pid === child.pid ? Effect.void : Effect.sync(() => { child.kill("SIGTERM") }), ), Effect.map((found) => found.transport), Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")), ) }) 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.info, options) yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore) }) function fallback() { const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state") return join(state, "opencode", "service.json") } function auth(password: string): RequestInit["headers"] { return { authorization: "Basic " + btoa("opencode:" + password) } } 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), }) export type Info = typeof Info.Type const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decodeHealth = Schema.decodeUnknownOption( Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }), ) const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) })) // 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 const text = yield* fs.readFileString(file ?? fallback()).pipe(Effect.option) if (Option.isNone(text)) return undefined return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined)) }) type LocalService = { readonly info: Info readonly transport: Transport } const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { const headers = info.password === undefined ? undefined : auth(info.password) const response = yield* Effect.tryPromise(() => fetch(new URL("/api/health", info.url), { headers, signal: AbortSignal.timeout(2_000), }), ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) if (response === undefined || !response.ok) return undefined const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) const health = decodeHealth(body) if (Option.isSome(health)) { if (health.value.pid !== info.pid) return undefined if (info.version !== undefined && health.value.version !== info.version) return undefined if (version !== undefined && health.value.version !== version) return undefined return { info, transport: { url: info.url, headers } } satisfies LocalService } if ( !allowLegacy || Option.isNone(decodeLegacyHealth(body)) || (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) return undefined 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: Options) { const info = yield* read(options.file) if (info === undefined) return undefined return yield* probe(info, undefined, true) }) // 50ms cadence bounded at ~5s, shared by stop escalation and start readiness. const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100))) const signal = (pid: number, name: NodeJS.Signals) => Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore) const stopped = 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`)) }) 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: 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.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.info, info)) return yield* signal(info.pid, "SIGKILL") yield* stopped(info.pid).pipe(Effect.retry(poll)) }) export * as Service from "./service.js"