refactor(cli): unify server endpoint handling

This commit is contained in:
Dax Raad 2026-07-08 21:51:37 -04:00
parent 984cab7938
commit be7a684791
18 changed files with 212 additions and 223 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>
}
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<typeof attach>
export function transport(options: SharedOptions): ReturnType<typeof shared>
export function transport(options: Options): ReturnType<typeof attach> | ReturnType<typeof shared>
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

View file

@ -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<ReturnType<OpenCodeClient["session"]["get"]>>
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<string | undefined> | undefined
const resolveAgent = () => {
@ -120,11 +120,10 @@ function localDirectory(directory?: string): string {
}
}
function startTransport(input: MiniCommandInput): Promise<Transport> {
function startEndpoint(input: MiniCommandInput): Promise<Service.Endpoint> {
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<Transport> {
)
}
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<Service.Endpoint>): 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<string> {
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
}

View file

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

View file

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

View file

@ -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<Service.Transport>,
reload?: () => Promise<void>,
) {
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) }
}

View file

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

View file

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

View file

@ -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<void>
link?: DialogLinkCredentials
server: {
endpoint: Service.Endpoint
discover?: () => Promise<Service.Endpoint>
reload?: () => Promise<void>
}
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
@ -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) {
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
client={input.client}
api={input.api}
discover={input.discover}
reload={input.reload}
client={createOpencodeClient({ ...options, directory })}
api={api}
discover={reconnect}
reload={input.server.reload}
>
<PermissionProvider>
<ProjectProvider>
@ -338,7 +358,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
link={input.link}
link={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</LocationProvider>
</EditorContextProvider>

View file

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

View file

@ -93,6 +93,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
if (url.pathname === "/api/fs/list")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/shell")