diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index de4d1a2b87..62c8c44d59 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -20,10 +20,10 @@ export default Runtime.handler( Effect.fn("cli.api")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) + const endpoint = found ?? (yield* Service.start(options)) const params = Option.getOrElse(input.param, () => ({})) - const request = yield* resolveRequest(transport, input.request, params) - const headers = new Headers(transport.headers) + const request = yield* resolveRequest(endpoint, input.request, params) + const headers = new Headers(Service.headers(endpoint)) for (const header of input.header) { const index = header.indexOf(":") if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`)) @@ -33,7 +33,7 @@ export default Runtime.handler( if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") const response = yield* Effect.tryPromise(() => - fetch(new URL(request.path, transport.url), { + fetch(new URL(request.path, endpoint.url), { method: request.method, headers, body, @@ -60,7 +60,7 @@ export function rawRequest(input: readonly string[]) { } function resolveRequest( - transport: Service.Transport, + endpoint: Service.Endpoint, input: readonly string[], params: Record, ) { @@ -68,7 +68,7 @@ function resolveRequest( if (raw) return Effect.succeed(raw) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) return Effect.tryPromise(async () => { - const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers }) + const response = await fetch(new URL("/openapi.json", endpoint.url), { headers: Service.headers(endpoint) }) if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`) return resolveOperation((await response.json()) as OpenApi, input[0], params) }) diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 959263e89d..973fe1a326 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -1,9 +1,10 @@ import { Cause, Effect, Exit, Option } from "effect" +import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { AppProcess } from "@opencode-ai/core/process" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" -import { Daemon } from "../../../daemon" +import { ServiceConfig } from "../../../services/service-config" import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" const integrationID = "opencode" @@ -34,8 +35,8 @@ 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 transport = yield* Daemon.transport({ mode: "shared" }) - const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = yield* Service.start(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") const method = yield* required( diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 1dbaf4d801..ec7925fc44 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -11,8 +11,8 @@ export default Runtime.handler( Effect.fn("cli.debug.agents")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) 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 62c3ace63f..6f8ab62b08 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,4 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { run } from "@opencode-ai/tui" +import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" +import { TuiConfig } from "@opencode-ai/tui/config" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Effect, Option, Redacted } from "effect" @@ -10,26 +15,29 @@ import { Updater } from "../../services/updater" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { - const directory = Option.getOrUndefined(input.directory) - if (directory !== undefined) process.chdir(directory) + const requestedDirectory = Option.getOrUndefined(input.directory) + if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.forkScoped) 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* Effect.gen(function* () { + const endpoint = yield* Effect.gen(function* () { if (server !== undefined) { const password = yield* Env.password const explicit = { url: server, - headers: password - ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } + auth: password + ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, - } satisfies Service.Transport + } satisfies Service.Endpoint // Fail loudly before entering the TUI: an explicit server that is // unreachable or rejects auth should not present as reconnect churn. const response = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", server), { headers: explicit.headers, signal: AbortSignal.timeout(5_000) }), + fetch(new URL("/api/health", server), { + headers: Service.headers(explicit), + signal: AbortSignal.timeout(5_000), + }), ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause }))) if (response.status === 401) return yield* Effect.fail( @@ -43,14 +51,13 @@ export default Runtime.handler(Commands, (input) => return yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`)) return explicit } - if (input.standalone) return yield* Standalone.transport() + if (input.standalone) return yield* Standalone.start() const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) return found ?? (yield* Service.start(options)) }) - const { runTui } = yield* Effect.promise(() => import("../../tui")) // The TUI re-runs discover whenever its event stream drops. For an explicit - // --server or a standalone child the transport is fixed, so reconnects + // --server or a standalone child the endpoint is fixed, so reconnects // retry the same address; for the managed service discovery re-reads the // registration and may start a replacement. const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined @@ -65,7 +72,7 @@ export default Runtime.handler(Commands, (input) => return found ?? (yield* Service.start(reconnectOptions)) }).pipe(Effect.provide(NodeFileSystem.layer)), ) - : () => Promise.resolve(transport) + : undefined // Restart the managed service in place; start() resolves once the // replacement is healthy and the reconnect loop reattaches on its own. // Only meaningful in service mode: --server is not ours to restart and a @@ -79,11 +86,36 @@ export default Runtime.handler(Commands, (input) => }).pipe(Effect.provide(NodeFileSystem.layer)), ) : undefined - yield* runTui( - transport, - { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, - discover, - reload, - ) + const config = TuiConfig.resolve({}, { terminalSuspend: false }) + let disposeSlots: (() => void) | undefined + const runFork = Effect.runForkWith(yield* Effect.context()) + yield* run({ + server: { + endpoint, + discover, + reload, + }, + args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, + config, + log: (level, message, tags) => { + const effect = + level === "debug" + ? Effect.logDebug(message, tags) + : level === "warn" + ? Effect.logWarning(message, tags) + : level === "error" + ? Effect.logError(message, tags) + : Effect.logInfo(message, tags) + runFork(effect) + }, + pluginHost: { + async start(pluginInput) { + disposeSlots = await loadBuiltinPlugins(pluginInput.api, pluginInput.runtime) + }, + async dispose() { + disposeSlots?.() + }, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) }), ) diff --git a/packages/cli/src/commands/handlers/link.ts b/packages/cli/src/commands/handlers/link.ts index d4d5c02ab1..6612502553 100644 --- a/packages/cli/src/commands/handlers/link.ts +++ b/packages/cli/src/commands/handlers/link.ts @@ -10,10 +10,10 @@ import { ServiceConfig } from "../../services/service-config" export default Runtime.handler( Commands.commands.link, Effect.fn("cli.link")(function* () { - const transport = yield* Service.start(yield* ServiceConfig.options()) + const endpoint = yield* Service.start(yield* ServiceConfig.options()) const password = yield* ServiceConfig.password() const server = yield* Effect.tryPromise(() => - OpenCode.make({ baseUrl: transport.url, headers: transport.headers }).server.get(), + OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.get(), ) const info = { urls: server.urls, username: "opencode", password } process.stdout.write( @@ -34,7 +34,7 @@ export default Runtime.handler( ].join(EOL) + EOL, ) - const hostname = new URL(transport.url).hostname + const hostname = new URL(endpoint.url).hostname if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return process.stderr.write( ` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 967f24e28f..03a5e687fd 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -19,8 +19,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.auth")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) 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 2d38a6aca0..3c44a839e3 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -11,8 +11,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.list")(function* () { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) 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 94a3114cfa..271953c306 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -14,8 +14,8 @@ export default Runtime.handler( Effect.fn("cli.mcp.logout")(function* (input) { const options = yield* ServiceConfig.options() const found = yield* Service.discover(options) - const transport = found ?? (yield* Service.start(options)) - const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) + const endpoint = found ?? (yield* Service.start(options)) + const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const integration = yield* resolveIntegration(client, input.name, location) if (!integration) { diff --git a/packages/cli/src/daemon.ts b/packages/cli/src/daemon.ts index 1e30a35941..71d4b34b8d 100644 --- a/packages/cli/src/daemon.ts +++ b/packages/cli/src/daemon.ts @@ -1,59 +1,35 @@ import { Service } from "@opencode-ai/client/effect" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ServerAuth } from "@opencode-ai/server/auth" import { Effect } from "effect" -import { ServiceConfig } from "./services/service-config" -export type SharedOptions = { - readonly mode: "shared" - readonly command?: ReadonlyArray -} -export type AttachOptions = { - readonly mode: "attach" +type ConnectOptions = { readonly url: string readonly username?: string readonly password?: string } -export type Options = SharedOptions | AttachOptions -const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) { - const transport = { +export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) { + const endpoint = { url: options.url, - headers: + auth: options.password === undefined ? undefined - : ServerAuth.headers({ password: options.password, username: options.username }), - } satisfies Service.Transport - const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) + : { type: "basic" as const, username: options.username ?? "opencode", password: options.password }, + } satisfies Service.Endpoint + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const health = yield* Effect.tryPromise({ try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), - catch: (cause) => attachError(options, cause), + catch: (cause) => connectError(options, cause), }) if (health.version !== InstallationVersion) process.stderr.write( `Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, ) - return transport + return endpoint }) -const shared = Effect.fn("cli.daemon.shared")(function* (options: SharedOptions) { - const config = yield* ServiceConfig.options() - const service = options.command === undefined ? config : { ...config, command: options.command } - const found = yield* Service.discover(service) - if (found) return found - return yield* Service.start(service) -}) - -export function transport(options: AttachOptions): ReturnType -export function transport(options: SharedOptions): ReturnType -export function transport(options: Options): ReturnType | ReturnType -export function transport(options: Options) { - if (options.mode === "attach") return attach(options) - return shared(options) -} - -function attachError(options: AttachOptions, cause: unknown) { +function connectError(options: ConnectOptions, cause: unknown) { if (isUnauthorizedError(cause)) { return new Error( options.password === undefined diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 8a47529816..8024f7b138 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,9 +1,11 @@ import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" import path from "node:path" import { Daemon } from "../daemon" +import { ServiceConfig } from "../services/service-config" import { waitForCatalogReady } from "./catalog.shared" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import type { RunInput, RunTuiConfig } from "./types" @@ -27,28 +29,26 @@ export type MiniCommandInput = { } type Session = Awaited> -type Transport = { readonly url: string; readonly headers?: HeadersInit } - export async function runMini(input: MiniCommandInput) { validate(input) const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) const runtimeTask = import("./runtime") const directory = input.attach ? input.directory : localDirectory(input.directory) - const transportTask = startTransport(input) - void transportTask.catch(() => {}) + const endpointTask = startEndpoint(input) + void endpointTask.catch(() => {}) try { - if (input.attach) await transportTask + if (input.attach) await endpointTask const sdk = OpenCode.make({ baseUrl: "http://opencode.pending", - fetch: deferredFetch(transportTask), + fetch: deferredFetch(endpointTask), }) const attachedSession = input.attach && input.session && !input.directory ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) : undefined const resolvedDirectory = - directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await transportTask, sdk)) + directory ?? attachedSession?.location.directory ?? (await remoteDirectory(await endpointTask, sdk)) const model = parseModel(input.model) let agentTask: Promise | undefined const resolveAgent = () => { @@ -120,11 +120,10 @@ function localDirectory(directory?: string): string { } } -function startTransport(input: MiniCommandInput): Promise { +function startEndpoint(input: MiniCommandInput): Promise { if (input.attach) { return Effect.runPromise( - Daemon.transport({ - mode: "attach", + Daemon.connect({ url: input.attach, password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, @@ -132,31 +131,36 @@ function startTransport(input: MiniCommandInput): Promise { ) } return Effect.runPromise( - Daemon.transport({ mode: "shared", command: input.serverCommand }).pipe( + Effect.gen(function* () { + const options = yield* ServiceConfig.options() + return yield* Service.start( + input.serverCommand === undefined ? options : { ...options, command: input.serverCommand }, + ) + }).pipe( Effect.provide(NodeFileSystem.layer), Effect.provide(Global.layerWith({})), ), ) } -function deferredFetch(transportTask: Promise<{ url: string; headers?: HeadersInit }>): typeof globalThis.fetch { +function deferredFetch(endpointTask: Promise): typeof globalThis.fetch { const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const transport = await transportTask + const endpoint = await endpointTask const request = new Request(input, init) const source = new URL(request.url) const headers = new Headers(request.headers) - for (const [key, value] of new Headers(transport.headers)) headers.set(key, value) - return globalThis.fetch(new Request(new URL(source.pathname + source.search, transport.url), request), { headers }) + for (const [key, value] of new Headers(Service.headers(endpoint))) headers.set(key, value) + return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers }) } return fetch as typeof globalThis.fetch } async function remoteDirectory( - transport: { url: string; headers?: HeadersInit }, + endpoint: Service.Endpoint, sdk: OpenCodeClient, ): Promise { const location = await sdk.location.get() - if (!location.directory) throw new Error(`Failed to resolve remote directory from ${transport.url}`) + if (!location.directory) throw new Error(`Failed to resolve remote directory from ${endpoint.url}`) return location.directory } diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index a6aedbb97d..a35f03be72 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -1,4 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" +import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -7,6 +8,7 @@ import { Effect } from "effect" import { open } from "node:fs/promises" import path from "node:path" import { Daemon } from "../daemon" +import { ServiceConfig } from "../services/service-config" import { Standalone } from "../services/standalone" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" @@ -39,8 +41,6 @@ type FilePart = { mime: string } -type Transport = { readonly url: string; readonly headers?: HeadersInit } - type Prepared = { directory?: string message: string @@ -67,17 +67,17 @@ async function run(input: RunCommandInput) { return Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const transport = yield* Standalone.transport({ command: input.standaloneCommand }) - yield* Effect.promise(() => execute(input, prepared, transport)) + const endpoint = yield* Standalone.start({ command: input.standaloneCommand }) + yield* Effect.promise(() => execute(input, prepared, endpoint)) }), ), ) - const transport = await startTransport(input) - return execute(input, prepared, transport) + const endpoint = await startEndpoint(input) + return execute(input, prepared, endpoint) } -async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) { - const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) +async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) { + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const requestedDirectory = prepared.directory ?? (await client.location.get()).directory if (!requestedDirectory) fail("Failed to resolve server directory") const session = await selectSession(client, requestedDirectory, input) @@ -163,11 +163,10 @@ function localDirectory(directory: string | undefined, root: string) { } } -function startTransport(input: RunCommandInput) { +function startEndpoint(input: RunCommandInput) { if (input.server) { return Effect.runPromise( - Daemon.transport({ - mode: "attach", + Daemon.connect({ url: input.server, password: input.password, username: input.username, @@ -175,7 +174,9 @@ function startTransport(input: RunCommandInput) { ) } return Effect.runPromise( - Daemon.transport({ mode: "shared" }).pipe( + Effect.gen(function* () { + return yield* Service.start(yield* ServiceConfig.options()) + }).pipe( Effect.provide(NodeFileSystem.layer), Effect.provide(Global.layerWith({})), ), diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index c0a8d8b71d..099b1c9df1 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -1,4 +1,4 @@ -import { ServerAuth } from "@opencode-ai/server/auth" +import { Service } from "@opencode-ai/client/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Schema, Stream } from "effect" @@ -34,7 +34,7 @@ function command(password: string, options: Options) { }) } -const makeTransport = Effect.fn("cli.standalone.transport")( +const makeEndpoint = Effect.fn("cli.standalone.endpoint")( function* (options: Options) { const password = randomBytes(32).toString("base64url") const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -42,13 +42,17 @@ const makeTransport = Effect.fn("cli.standalone.transport")( const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness")) const ready = yield* Effect.tryPromise(() => decodeReady(output)) - return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid } + return { + url: ready.url, + auth: { type: "basic" as const, username: "opencode", password }, + pid: proc.pid, + } satisfies Service.Endpoint & { readonly pid: number } }, Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), ) -export function transport(options: Options = {}) { - return makeTransport(options) +export function start(options: Options = {}) { + return makeEndpoint(options) } export * as Standalone from "./standalone" diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts deleted file mode 100644 index 8babf9b253..0000000000 --- a/packages/cli/src/tui.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { run } from "@opencode-ai/tui" -import { TuiConfig } from "@opencode-ai/tui/config" -import { Effect } from "effect" -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 { 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: Service.Transport, - args: Args, - discover?: () => Promise, - reload?: () => Promise, -) { - const config = TuiConfig.resolve({}, { terminalSuspend: false }) - let disposeSlots: (() => void) | undefined - return Effect.gen(function* () { - const runFork = Effect.runForkWith(yield* Effect.context()) - const options = { baseUrl: transport.url, headers: transport.headers } - const api = OpenCode.make(options) - const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( - Effect.map((response) => response.location.directory), - Effect.catch(() => - Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), - ), - ) - return yield* run({ - client: createOpencodeClient({ ...options, directory }), - api, - link: linkCredentials(transport), - discover: discover - ? async () => { - const next = await discover() - return { - client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }), - api: OpenCode.make({ baseUrl: next.url, headers: next.headers }), - } - } - : undefined, - reload, - args, - config, - log: (level, message, tags) => { - const effect = - level === "debug" - ? Effect.logDebug(message, tags) - : level === "warn" - ? Effect.logWarning(message, tags) - : level === "error" - ? Effect.logError(message, tags) - : Effect.logInfo(message, tags) - runFork(effect) - }, - pluginHost: { - async start(input) { - disposeSlots = await loadBuiltinPlugins(input.api, input.runtime) - }, - async dispose() { - disposeSlots?.() - }, - }, - }) - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))) -} - -function linkCredentials(transport: Service.Transport) { - const authorization = new Headers(transport.headers).get("authorization") - if (!authorization?.startsWith("Basic ")) return { username: "opencode", password: "" } - const value = atob(authorization.slice("Basic ".length)) - const separator = value.indexOf(":") - if (separator === -1) return { username: "opencode", password: "" } - return { username: value.slice(0, separator), password: value.slice(separator + 1) } -} diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 1ad311890a..7f92ac0636 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import { Service } from "@opencode-ai/client/effect" import path from "node:path" import { Standalone } from "../../src/services/standalone" @@ -7,9 +8,11 @@ process.argv[1] = path.join(import.meta.dir, "../../src/index.ts") await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const transport = yield* Standalone.transport() - const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers })) - console.log(`${transport.pid} ${transport.url} ${response.status}`) + const endpoint = yield* Standalone.start() + const response = yield* Effect.promise(() => + fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }), + ) + console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`) return yield* Effect.never }), ), diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 9c5e833ee1..3e5d3ec0f2 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -11,9 +11,13 @@ import { join } from "node:path" // 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 = { +export type Endpoint = { readonly url: string - readonly headers?: RequestInit["headers"] + readonly auth?: { + readonly type: "basic" + readonly username: string + readonly password: string + } } export type Options = { @@ -31,7 +35,7 @@ export type Options = { // 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 + return (yield* discoverLocal(options))?.endpoint }) const discoverLocal = Effect.fnUntraced(function* (options: Options) { @@ -72,7 +76,7 @@ export const start = Effect.fn("service.start")(function* (options: Options = {} child.kill("SIGTERM") }), ), - Effect.map((found) => found.transport), + Effect.map((found) => found.endpoint), Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")), ) @@ -90,8 +94,9 @@ function fallback() { return join(state, "opencode", "service.json") } -function auth(password: string): RequestInit["headers"] { - return { authorization: "Basic " + btoa("opencode:" + password) } +export function headers(endpoint: Endpoint): RequestInit["headers"] { + if (endpoint.auth === undefined) return undefined + return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) } } export const Info = Schema.Struct({ @@ -120,14 +125,20 @@ const read = Effect.fnUntraced(function* (file?: string) { type LocalService = { readonly info: Info - readonly transport: Transport + readonly endpoint: Endpoint } const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { - const headers = info.password === undefined ? undefined : auth(info.password) + const endpoint = { + url: info.url, + auth: + info.password === undefined + ? undefined + : { type: "basic" as const, username: "opencode", password: info.password }, + } satisfies Endpoint const response = yield* Effect.tryPromise(() => fetch(new URL("/api/health", info.url), { - headers, + headers: headers(endpoint), signal: AbortSignal.timeout(2_000), }), ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) @@ -138,7 +149,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe 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 + return { info, endpoint } satisfies LocalService } if ( !allowLegacy || @@ -146,7 +157,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLe (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) ) return undefined - return { info, transport: { url: info.url, headers } } satisfies LocalService + return { info, endpoint } satisfies LocalService }) // Health-checked lookup without the version gate: lifecycle operations must be diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ccad43ff50..c1262eda83 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -2,6 +2,8 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op import { registerOpencodeSpinner } from "./component/register-spinner" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Deferred, Effect } from "effect" +import { Service } from "@opencode-ai/client/effect" +import { OpenCode } from "@opencode-ai/client/promise" import { Global } from "@opencode-ai/core/global" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -46,6 +48,7 @@ import { DialogMcp } from "./component/dialog-mcp" import { DialogStatus } from "./component/dialog-status" import { DialogDebug } from "./component/dialog-debug" import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" @@ -81,8 +84,6 @@ import { useOpencodeKeymap, } from "./keymap" -import type { OpenCodeClient } from "@opencode-ai/client/promise" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -144,11 +145,11 @@ const appBindingCommands = [ ] as const export type TuiInput = { - client: OpencodeClient - api: OpenCodeClient - discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> - reload?: () => Promise - link?: DialogLinkCredentials + server: { + endpoint: Service.Endpoint + discover?: () => Promise + reload?: () => Promise + } args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -191,6 +192,25 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const log = input.log ?? (() => {}) const global = yield* Global.Service + const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) } + const api = OpenCode.make(options) + const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( + Effect.map((response) => response.location.directory), + Effect.catch(() => + Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), + ), + ) + const discover = input.server.discover + const reconnect = discover + ? async () => { + const endpoint = await discover() + const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } + return { + client: createOpencodeClient({ ...next, directory }), + api: OpenCode.make(next), + } + } + : undefined const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } const result = yield* Effect.scoped( Effect.gen(function* () { @@ -317,10 +337,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { @@ -338,7 +358,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 626c8fd03e..c1530ee189 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -5,7 +5,7 @@ import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { createTuiResolvedConfig } from "./fixture/tui-runtime" -import { createApi, createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" test("SIGHUP clears title and disposes scoped resources once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) @@ -20,6 +20,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const listeners = new Set(process.listeners("SIGHUP")) const events = createEventStream() const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) let started!: () => void const ready = new Promise((resolve) => { started = resolve @@ -30,8 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ - client: createClient(calls.fetch), - api: createApi(calls.fetch), + server: { endpoint: { url: server.url.toString() } }, config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, log: () => {}, @@ -55,6 +55,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) } finally { if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() mock.restore() } }) @@ -79,22 +80,25 @@ test("session lifecycle updates the terminal title and prints the epilogue after } const events = createEventStream() const calls = createFetch((url) => { + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } if (url.pathname === "/api/session") return json({ - data: [ - { - id: "dummy", - title: "Demo session", - projectID: "project", - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - }, - ], + data: [session], cursor: {}, }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" let api: TuiPluginApi | undefined @@ -112,8 +116,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after const { run } = await import("../src/app") const task = Effect.runPromise( run({ - client: createClient(calls.fetch), - api: createApi(calls.fetch), + server: { endpoint: { url: server.url.toString() } }, config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { sessionID: "dummy" }, log: () => {}, @@ -145,6 +148,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after } finally { process.stdout.write = originalWrite if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() mock.restore() } }) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 2c10ed43a2..8d280cab80 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -93,6 +93,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType