diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 9025c67c21..99af278a35 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -35,7 +35,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo yield* request(() => timeline.intro("Log in")) yield* request(() => timeline.pending("Connecting to OpenCode...")) - const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) const integration = yield* required(found.data, "OpenCode Console integration is unavailable") diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index f3cd7c1b12..c178e90b45 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -11,7 +11,7 @@ export default Runtime.handler( Effect.fn("cli.debug.agents")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } })) process.stdout.write( diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index a6ebe09b47..32e728b435 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -19,7 +19,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.auth")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index a44aa9af9c..590fb818c5 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -11,7 +11,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.list")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } })) const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index 7e1aaac1d2..47dc3052a6 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -14,7 +14,7 @@ export default Runtime.handler( Effect.fn("cli.mcp.logout")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const endpoint = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.ensure(options)) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index be4c8d97fc..edbae68f6c 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -10,7 +10,7 @@ import { ServiceConfig } from "../../services/service-config" export default Runtime.handler( Commands.commands.pair, Effect.fn("cli.pair")(function* () { - const endpoint = yield* Service.start(yield* ServiceConfig.options()) + const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const password = yield* ServiceConfig.password() const server = yield* Effect.tryPromise(() => OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(), diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index fbe1ecca63..fe306dcb02 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -10,7 +10,7 @@ export default Runtime.handler( Effect.fn("cli.service.restart")(function* () { const options = yield* ServiceConfig.options() yield* Service.stop(options) - const transport = yield* Service.start(options) + const transport = yield* Service.ensure(options) process.stdout.write(transport.url + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index 8dff91376f..dfdbac3997 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -8,7 +8,7 @@ import { ServiceConfig } from "../../../services/service-config" export default Runtime.handler( Commands.commands.service.commands.start, Effect.fn("cli.service.start")(function* () { - const transport = yield* Service.start(yield* ServiceConfig.options()) + const transport = yield* Service.ensure(yield* ServiceConfig.options()) process.stdout.write(transport.url + EOL) }), ) diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 87b2c81e7c..4eccc95bc9 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -1,4 +1,4 @@ -import { Service, type Endpoint, type StartOptions } from "@opencode-ai/client/effect/service" +import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Redacted } from "effect" @@ -10,7 +10,7 @@ export type Args = { readonly server?: string readonly standalone?: boolean readonly mismatch?: "replace" | "ignore" | "error" - readonly onStart?: StartOptions["onStart"] + readonly onStart?: EnsureOptions["onStart"] } export type Resolved = { @@ -49,31 +49,31 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg } satisfies Resolved }) -function managedService(options: StartOptions) { +function managedService(options: EnsureOptions) { const reconnectOptions = { ...options, version: undefined } return { - reconnect: () => Service.start(reconnectOptions), + reconnect: () => Service.ensure(reconnectOptions), restart: () => Effect.gen(function* () { yield* Service.stop(options) - yield* Service.start(options) + yield* Service.ensure(options) }), } } const resolveManaged = Effect.fnUntraced(function* ( - options: StartOptions, + options: EnsureOptions, mismatch: NonNullable, ) { - if (mismatch === "replace") return yield* Service.start(options) - if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined }) + if (mismatch === "replace") return yield* Service.ensure(options) + if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined }) const compatible = yield* Service.discover(options) if (compatible !== undefined) return compatible const existing = yield* Service.discover({ ...options, version: undefined }) if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client")) - return yield* Service.start(options) + return yield* Service.ensure(options) }) function connectError(endpoint: Endpoint, cause: unknown) { diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 80e0fb42b0..2446d30730 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -6,7 +6,7 @@ import { Effect, FileSystem, Option, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" -// The CLI's service configuration file, plus the Service.StartOptions binding that +// The CLI's service configuration file, plus the Service.EnsureOptions 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/client/src/effect/service.ts b/packages/client/src/effect/service.ts index aad67dba3c..e4bf5bbefb 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -6,7 +6,7 @@ import { join } from "node:path" import type { DiscoverOptions, Endpoint, - StartOptions, + EnsureOptions, StopOptions, } from "../service.js" @@ -28,7 +28,7 @@ type Contender = { } // Read-only lookup: registration file plus health check and version gate. -// Never spawns; escalation to start() is the caller's policy. +// Never spawns; escalation to ensure() is the caller's policy. /** Discover a healthy, compatible local service without starting one. */ export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) { return (yield* discoverLocal(options))?.endpoint @@ -45,7 +45,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) { // version-mismatched one, and otherwise spawns small contenders until a server // becomes discoverable. A contender is never killed merely for slow startup. /** Ensure a healthy, compatible local service is running. */ -export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) { +export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) { const contenders = new Set() let announced = false let lastSpawn = 0 @@ -219,7 +219,7 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) { return (yield* registered(options.file, true)).service }) -// 50ms cadence bounded at ~5s, shared by stop escalation and each start +// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure // discovery window. const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100))) @@ -280,4 +280,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) { }) /** Effect-based local service lifecycle operations. */ -export const Service = { discover, start, stop, headers, Info } +export const Service = { discover, ensure, stop, headers, Info } diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index e6037e387b..aecaf95f4f 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -6,7 +6,7 @@ import type { DiscoverOptions, Endpoint, Info, - StartOptions, + EnsureOptions, StopOptions, } from "../service.js" import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js" @@ -37,7 +37,7 @@ async function discoverLocal(options: DiscoverOptions) { } /** Ensure a healthy, compatible local service is running. */ -export async function start(options: StartOptions = {}): Promise { +export async function ensure(options: EnsureOptions = {}): Promise { const contenders = new Set() let announced = false let lastSpawn = 0 @@ -249,4 +249,4 @@ function delay(milliseconds: number) { } /** Promise-based local service lifecycle operations. */ -export const Service = { discover, start, stop, headers } +export const Service = { discover, ensure, stop, headers } diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts index 11254fc843..96d54871f6 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/service.ts @@ -21,15 +21,15 @@ export type DiscoverOptions = { readonly version?: string } -/** Reason a new service process must be started. */ -export type StartReason = "missing" | "version-mismatch" +/** Reason ensuring the service requires a new process. */ +export type EnsureReason = "missing" | "version-mismatch" /** Options used to ensure the local OpenCode service is running. */ -export type StartOptions = DiscoverOptions & { +export type EnsureOptions = DiscoverOptions & { /** Service command and arguments. Defaults to `opencode serve --service`. */ readonly command?: ReadonlyArray /** Called once before spawning a new service process. */ - readonly onStart?: (reason: StartReason, previousVersion?: string) => void + readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void } /** Options used to stop the local OpenCode service. */ diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts index dc422f07bc..1cf6757a78 100644 --- a/packages/client/test/promise-service.test.ts +++ b/packages/client/test/promise-service.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Service, type StartReason } from "../src/promise/service" +import { Service, type EnsureReason } from "../src/promise/service" const fixture = join(import.meta.dir, "fixture/service.ts") const processes: Bun.Subprocess[] = [] @@ -23,12 +23,12 @@ test("discovers a registered service", async () => { expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined() }) -test("starts a missing service with native promises", async () => { +test("ensures a missing service with native promises", async () => { const directory = await temp() const registration = join(directory, "service.json") - const starts: StartReason[] = [] + const starts: EnsureReason[] = [] - const endpoint = await Service.start({ + const endpoint = await Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "coordinated"], @@ -47,7 +47,7 @@ test("starts a missing service with native promises", async () => { test("reports a failed registered service", async () => { const registration = await setup("failed-owner") - await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow( + await expect(Service.ensure({ file: registration, version: "test", command: [] })).rejects.toThrow( "Background service failed to start", ) }) diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index c3ebcb1580..8e73536bca 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -4,7 +4,7 @@ import { Effect } from "effect" import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Service, type StartReason } from "../src/effect/service" +import { Service, type EnsureReason } from "../src/effect/service" const fixture = join(import.meta.dir, "fixture/service.ts") const processes: Bun.Subprocess[] = [] @@ -23,9 +23,9 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as await waitForFile(registration) const original = await Bun.file(registration).json() - const starts: StartReason[] = [] + const starts: EnsureReason[] = [] const first = run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [], @@ -34,7 +34,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as ) await waitForFile(registration + ".first-request") - const resolved = await run(Service.start({ file: registration, version: "test" })) + const resolved = await run(Service.ensure({ file: registration, version: "test" })) expect(resolved.url).toBe(original.url) await writeFile(registration + ".release", "") @@ -50,7 +50,7 @@ test("waits for a registered service to finish starting", async () => { const registration = join(directory, "service.json") const process = spawn(registration, "starting") await waitForFile(registration) - const result = run(Service.start({ file: registration, version: "test", command: [] })) + const result = run(Service.ensure({ file: registration, version: "test", command: [] })) await Bun.sleep(500) expect(process.exitCode).toBe(null) @@ -64,7 +64,7 @@ test("reports a failed registered service without spawning", async () => { const process = spawn(registration, "failed-owner") await waitForFile(registration) - await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow( + await expect(run(Service.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow( "Background service failed to start", ) expect(process.exitCode).toBe(null) @@ -90,7 +90,7 @@ test("does not spawn contenders while an incompatible service rejects replacemen await waitForFile(registration) const controller = new AbortController() const starting = Effect.runPromise( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, contender, "record-start"], @@ -113,8 +113,8 @@ test("a legacy health response is still replaced", async () => { const existing = spawn(registration, "legacy") await waitForFile(registration) - const starts: StartReason[] = [] - const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) })) + const starts: EnsureReason[] = [] + const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) })) await expect(result).rejects.toThrow("Missing service command") expect(starts).toEqual(["version-mismatch"]) @@ -125,7 +125,7 @@ test("waits for a slow winner while bounding lock probes", async () => { const directory = await temp() const registration = join(directory, "service.json") const endpoint = await run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "coordinated"], @@ -146,7 +146,7 @@ test("reports a contender that fails to start", async () => { const registration = join(directory, "service.json") await expect( run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "failed"], @@ -160,7 +160,7 @@ test("reports a contender terminated by a signal", async () => { const registration = join(directory, "service.json") await expect( run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "signal"], @@ -174,7 +174,7 @@ test("reports a slow contender that eventually fails", async () => { const registration = join(directory, "service.json") await expect( run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "delayed-failed", "8000"], @@ -187,7 +187,7 @@ test("replaces an incompatible owner that appears during startup", async () => { const directory = await temp() const registration = join(directory, "service.json") const starting = run( - Service.start({ + Service.ensure({ file: registration, version: "test", command: [process.execPath, fixture, registration, "delayed", "8000"], diff --git a/packages/docs/build/client.mdx b/packages/docs/build/client.mdx index f759e04d45..0465dd6fed 100644 --- a/packages/docs/build/client.mdx +++ b/packages/docs/build/client.mdx @@ -70,23 +70,23 @@ for await (const event of client.event.subscribe()) { } ``` -## Local service +## Local background service -`Service` discovers and manages the local OpenCode background service from a -Node application. The Promise API uses Node APIs directly and does not require -Effect or `@effect/platform-node`. +The main client entrypoints are browser-compatible and do not include local +process management. In a Node application, import the native Promise service +API from `@opencode-ai/client/service`. - `Service.discover()` returns a healthy registered endpoint without starting a process. -- `Service.start()` reuses a compatible service or starts one when needed. -- `Service.stop()` stops the registered service. +- `Service.ensure()` returns a compatible service, starting one when needed. +- `Service.stop()` stops the exact registered service instance. - `Service.headers(endpoint)` creates the authentication headers for a client. ```ts import { OpenCode } from "@opencode-ai/client" import { Service } from "@opencode-ai/client/service" -const endpoint = await Service.start() +const endpoint = await Service.ensure() const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint), @@ -95,7 +95,22 @@ const client = OpenCode.make({ const health = await client.health.get() ``` -Import the native Promise service API from `@opencode-ai/client/service`. +`Service.ensure()` accepts an optional registration file, required version, +service command, and `onStart` callback: + +```ts +const endpoint = await Service.ensure({ + file: "/var/run/opencode/service.json", + version: "2.0.0", + command: ["opencode", "serve", "--service"], + onStart(reason, previousVersion) { + console.log(reason, previousVersion) + }, +}) +``` + +Omit these options to use the standard registration path and +`opencode serve --service` command. ## Effect @@ -133,11 +148,11 @@ const session = await Effect.runPromise( Streaming operations, including `client.event.subscribe()` and `client.session.log(...)`, return Effect `Stream` values. -### Local service +### Local background service -The Effect entrypoint exposes the same service lifecycle operations as Effect -values. Add `@effect/platform-node` and provide its filesystem layer when -running service operations. +The Node-only `@opencode-ai/client/effect/service` entrypoint exposes the same +operations as Effect values. Add `@effect/platform-node` and provide its +filesystem layer when running them. ```sh bun add @effect/platform-node @@ -151,7 +166,7 @@ import { Effect } from "effect" import { FetchHttpClient } from "effect/unstable/http" const program = Effect.gen(function* () { - const endpoint = yield* Service.start() + const endpoint = yield* Service.ensure() const client = yield* OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint), diff --git a/packages/www/content/docs/build/client.mdx b/packages/www/content/docs/build/client.mdx index a306c8e271..ace8cc573b 100644 --- a/packages/www/content/docs/build/client.mdx +++ b/packages/www/content/docs/build/client.mdx @@ -113,7 +113,7 @@ Node application: - `Service.discover()` returns a healthy registered endpoint without starting a process. -- `Service.start()` reuses a compatible service or starts one when needed. +- `Service.ensure()` returns a compatible service, starting one when needed. - `Service.stop()` stops the registered service. - `Service.headers(endpoint)` creates the authentication headers for a client. @@ -129,7 +129,7 @@ import { Effect } from "effect" import { FetchHttpClient } from "effect/unstable/http" const program = Effect.gen(function* () { - const endpoint = yield* Service.start() + const endpoint = yield* Service.ensure() const client = yield* OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint),