refactor(cli): unify server endpoint handling

This commit is contained in:
Dax Raad
2026-07-08 22:10:30 -04:00
parent 984cab7938
commit be7a684791
18 changed files with 212 additions and 223 deletions
+6 -6
View File
@@ -20,10 +20,10 @@ export default Runtime.handler(
Effect.fn("cli.api")(function* (input) { Effect.fn("cli.api")(function* (input) {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(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 params = Option.getOrElse(input.param, () => ({}))
const request = yield* resolveRequest(transport, input.request, params) const request = yield* resolveRequest(endpoint, input.request, params)
const headers = new Headers(transport.headers) const headers = new Headers(Service.headers(endpoint))
for (const header of input.header) { for (const header of input.header) {
const index = header.indexOf(":") const index = header.indexOf(":")
if (index < 1) return yield* Effect.fail(new Error(`Invalid header, expected name:value: ${header}`)) 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") if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
const response = yield* Effect.tryPromise(() => const response = yield* Effect.tryPromise(() =>
fetch(new URL(request.path, transport.url), { fetch(new URL(request.path, endpoint.url), {
method: request.method, method: request.method,
headers, headers,
body, body,
@@ -60,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
} }
function resolveRequest( function resolveRequest(
transport: Service.Transport, endpoint: Service.Endpoint,
input: readonly string[], input: readonly string[],
params: Record<string, string>, params: Record<string, string>,
) { ) {
@@ -68,7 +68,7 @@ function resolveRequest(
if (raw) return Effect.succeed(raw) 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")) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
return Effect.tryPromise(async () => { 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}`) if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`)
return resolveOperation((await response.json()) as OpenApi, input[0], params) return resolveOperation((await response.json()) as OpenApi, input[0], params)
}) })
@@ -1,9 +1,10 @@
import { Cause, Effect, Exit, Option } from "effect" import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../daemon" import { ServiceConfig } from "../../../services/service-config"
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline" import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
const integrationID = "opencode" 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.intro("Log in"))
yield* request(() => timeline.pending("Connecting to OpenCode...")) yield* request(() => timeline.pending("Connecting to OpenCode..."))
const transport = yield* Daemon.transport({ mode: "shared" }) const endpoint = yield* Service.start(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal })) const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
const integration = yield* required(found.data, "OpenCode Console integration is unavailable") const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
const method = yield* required( const method = yield* required(
@@ -11,8 +11,8 @@ export default Runtime.handler(
Effect.fn("cli.debug.agents")(function* () { Effect.fn("cli.debug.agents")(function* () {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } })) const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write( process.stdout.write(
JSON.stringify( JSON.stringify(
+49 -17
View File
@@ -1,4 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node" 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 { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Effect, Option, Redacted } from "effect" import { Effect, Option, Redacted } from "effect"
@@ -10,26 +15,29 @@ import { Updater } from "../../services/updater"
export default Runtime.handler(Commands, (input) => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory) const requestedDirectory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory) if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
const updater = yield* Updater.Service const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped) yield* updater.check().pipe(Effect.forkScoped)
const server = Option.getOrUndefined(input.server) const server = Option.getOrUndefined(input.server)
if (server !== undefined && input.standalone) if (server !== undefined && input.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) 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) { if (server !== undefined) {
const password = yield* Env.password const password = yield* Env.password
const explicit = { const explicit = {
url: server, url: server,
headers: password auth: password
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) }
: undefined, : undefined,
} satisfies Service.Transport } satisfies Service.Endpoint
// Fail loudly before entering the TUI: an explicit server that is // Fail loudly before entering the TUI: an explicit server that is
// unreachable or rejects auth should not present as reconnect churn. // unreachable or rejects auth should not present as reconnect churn.
const response = yield* Effect.tryPromise(() => 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 }))) ).pipe(Effect.mapError((cause) => new Error(`Could not reach server at ${server}`, { cause })))
if (response.status === 401) if (response.status === 401)
return yield* Effect.fail( 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 yield* Effect.fail(new Error(`Server at ${server} responded with status ${response.status}`))
return explicit return explicit
} }
if (input.standalone) return yield* Standalone.transport() if (input.standalone) return yield* Standalone.start()
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
return found ?? (yield* Service.start(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 // 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 // retry the same address; for the managed service discovery re-reads the
// registration and may start a replacement. // registration and may start a replacement.
const serviceOptions = server === undefined && !input.standalone ? yield* ServiceConfig.options() : undefined 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)) return found ?? (yield* Service.start(reconnectOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)), }).pipe(Effect.provide(NodeFileSystem.layer)),
) )
: () => Promise.resolve(transport) : undefined
// Restart the managed service in place; start() resolves once the // Restart the managed service in place; start() resolves once the
// replacement is healthy and the reconnect loop reattaches on its own. // replacement is healthy and the reconnect loop reattaches on its own.
// Only meaningful in service mode: --server is not ours to restart and a // 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)), }).pipe(Effect.provide(NodeFileSystem.layer)),
) )
: undefined : undefined
yield* runTui( const config = TuiConfig.resolve({}, { terminalSuspend: false })
transport, let disposeSlots: (() => void) | undefined
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, const runFork = Effect.runForkWith(yield* Effect.context())
discover, yield* run({
reload, 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)))
}), }),
) )
+3 -3
View File
@@ -10,10 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
Commands.commands.link, Commands.commands.link,
Effect.fn("cli.link")(function* () { 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 password = yield* ServiceConfig.password()
const server = yield* Effect.tryPromise(() => 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 } const info = { urls: server.urls, username: "opencode", password }
process.stdout.write( process.stdout.write(
@@ -34,7 +34,7 @@ export default Runtime.handler(
].join(EOL) + EOL, ].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 if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write( process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`, ` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
@@ -19,8 +19,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.auth")(function* (input) { Effect.fn("cli.mcp.auth")(function* (input) {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) if (!integration)
@@ -11,8 +11,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.list")(function* () { Effect.fn("cli.mcp.list")(function* () {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) 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)) const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
if (servers.length === 0) { if (servers.length === 0) {
@@ -14,8 +14,8 @@ export default Runtime.handler(
Effect.fn("cli.mcp.logout")(function* (input) { Effect.fn("cli.mcp.logout")(function* (input) {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options) const found = yield* Service.discover(options)
const transport = found ?? (yield* Service.start(options)) const endpoint = found ?? (yield* Service.start(options))
const client = createOpencodeClient({ baseUrl: transport.url, headers: transport.headers }) const client = createOpencodeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location) const integration = yield* resolveIntegration(client, input.name, location)
if (!integration) { if (!integration) {
+10 -34
View File
@@ -1,59 +1,35 @@
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Effect } from "effect" import { Effect } from "effect"
import { ServiceConfig } from "./services/service-config"
export type SharedOptions = { type ConnectOptions = {
readonly mode: "shared"
readonly command?: ReadonlyArray<string>
}
export type AttachOptions = {
readonly mode: "attach"
readonly url: string readonly url: string
readonly username?: string readonly username?: string
readonly password?: string readonly password?: string
} }
export type Options = SharedOptions | AttachOptions
const attach = Effect.fn("cli.daemon.attach")(function* (options: AttachOptions) { export const connect = Effect.fn("cli.daemon.connect")(function* (options: ConnectOptions) {
const transport = { const endpoint = {
url: options.url, url: options.url,
headers: auth:
options.password === undefined options.password === undefined
? undefined ? undefined
: ServerAuth.headers({ password: options.password, username: options.username }), : { type: "basic" as const, username: options.username ?? "opencode", password: options.password },
} satisfies Service.Transport } satisfies Service.Endpoint
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({ const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => attachError(options, cause), catch: (cause) => connectError(options, cause),
}) })
if (health.version !== InstallationVersion) if (health.version !== InstallationVersion)
process.stderr.write( process.stderr.write(
`Warning: Server at ${options.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`, `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) { function connectError(options: ConnectOptions, cause: unknown) {
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) {
if (isUnauthorizedError(cause)) { if (isUnauthorizedError(cause)) {
return new Error( return new Error(
options.password === undefined options.password === undefined
+21 -17
View File
@@ -1,9 +1,11 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect" import { Effect } from "effect"
import path from "node:path" import path from "node:path"
import { Daemon } from "../daemon" import { Daemon } from "../daemon"
import { ServiceConfig } from "../services/service-config"
import { waitForCatalogReady } from "./catalog.shared" import { waitForCatalogReady } from "./catalog.shared"
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
import type { RunInput, RunTuiConfig } from "./types" import type { RunInput, RunTuiConfig } from "./types"
@@ -27,28 +29,26 @@ export type MiniCommandInput = {
} }
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>> type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Transport = { readonly url: string; readonly headers?: HeadersInit }
export async function runMini(input: MiniCommandInput) { export async function runMini(input: MiniCommandInput) {
validate(input) validate(input)
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt) const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
const runtimeTask = import("./runtime") const runtimeTask = import("./runtime")
const directory = input.attach ? input.directory : localDirectory(input.directory) const directory = input.attach ? input.directory : localDirectory(input.directory)
const transportTask = startTransport(input) const endpointTask = startEndpoint(input)
void transportTask.catch(() => {}) void endpointTask.catch(() => {})
try { try {
if (input.attach) await transportTask if (input.attach) await endpointTask
const sdk = OpenCode.make({ const sdk = OpenCode.make({
baseUrl: "http://opencode.pending", baseUrl: "http://opencode.pending",
fetch: deferredFetch(transportTask), fetch: deferredFetch(endpointTask),
}) })
const attachedSession = const attachedSession =
input.attach && input.session && !input.directory input.attach && input.session && !input.directory
? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found")) ? await sdk.session.get({ sessionID: input.session }).catch(() => fail("Session not found"))
: undefined : undefined
const resolvedDirectory = 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) const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => { 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) { if (input.attach) {
return Effect.runPromise( return Effect.runPromise(
Daemon.transport({ Daemon.connect({
mode: "attach",
url: input.attach, url: input.attach,
password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD, password: input.password ?? process.env.OPENCODE_SERVER_PASSWORD,
username: input.username ?? process.env.OPENCODE_SERVER_USERNAME, username: input.username ?? process.env.OPENCODE_SERVER_USERNAME,
@@ -132,31 +131,36 @@ function startTransport(input: MiniCommandInput): Promise<Transport> {
) )
} }
return Effect.runPromise( 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(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})), 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 fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const transport = await transportTask const endpoint = await endpointTask
const request = new Request(input, init) const request = new Request(input, init)
const source = new URL(request.url) const source = new URL(request.url)
const headers = new Headers(request.headers) const headers = new Headers(request.headers)
for (const [key, value] of new Headers(transport.headers)) headers.set(key, value) 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, transport.url), request), { headers }) return globalThis.fetch(new Request(new URL(source.pathname + source.search, endpoint.url), request), { headers })
} }
return fetch as typeof globalThis.fetch return fetch as typeof globalThis.fetch
} }
async function remoteDirectory( async function remoteDirectory(
transport: { url: string; headers?: HeadersInit }, endpoint: Service.Endpoint,
sdk: OpenCodeClient, sdk: OpenCodeClient,
): Promise<string> { ): Promise<string> {
const location = await sdk.location.get() 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 return location.directory
} }
+13 -12
View File
@@ -1,4 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -7,6 +8,7 @@ import { Effect } from "effect"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Daemon } from "../daemon" import { Daemon } from "../daemon"
import { ServiceConfig } from "../services/service-config"
import { Standalone } from "../services/standalone" import { Standalone } from "../services/standalone"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive" import { runNonInteractivePrompt } from "./noninteractive"
@@ -39,8 +41,6 @@ type FilePart = {
mime: string mime: string
} }
type Transport = { readonly url: string; readonly headers?: HeadersInit }
type Prepared = { type Prepared = {
directory?: string directory?: string
message: string message: string
@@ -67,17 +67,17 @@ async function run(input: RunCommandInput) {
return Effect.runPromise( return Effect.runPromise(
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const transport = yield* Standalone.transport({ command: input.standaloneCommand }) const endpoint = yield* Standalone.start({ command: input.standaloneCommand })
yield* Effect.promise(() => execute(input, prepared, transport)) yield* Effect.promise(() => execute(input, prepared, endpoint))
}), }),
), ),
) )
const transport = await startTransport(input) const endpoint = await startEndpoint(input)
return execute(input, prepared, transport) return execute(input, prepared, endpoint)
} }
async function execute(input: RunCommandInput, prepared: Prepared, transport: Transport) { async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory") if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input) 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) { if (input.server) {
return Effect.runPromise( return Effect.runPromise(
Daemon.transport({ Daemon.connect({
mode: "attach",
url: input.server, url: input.server,
password: input.password, password: input.password,
username: input.username, username: input.username,
@@ -175,7 +174,9 @@ function startTransport(input: RunCommandInput) {
) )
} }
return Effect.runPromise( 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(NodeFileSystem.layer),
Effect.provide(Global.layerWith({})), Effect.provide(Global.layerWith({})),
), ),
+9 -5
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect" 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) { function* (options: Options) {
const password = randomBytes(32).toString("base64url") const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner 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) 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")) if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output)) 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)), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
) )
export function transport(options: Options = {}) { export function start(options: Options = {}) {
return makeTransport(options) return makeEndpoint(options)
} }
export * as Standalone from "./standalone" export * as Standalone from "./standalone"
-76
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) }
}
@@ -1,4 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import path from "node:path" import path from "node:path"
import { Standalone } from "../../src/services/standalone" import { Standalone } from "../../src/services/standalone"
@@ -7,9 +8,11 @@ process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise( await Effect.runPromise(
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const transport = yield* Standalone.transport() const endpoint = yield* Standalone.start()
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers })) const response = yield* Effect.promise(() =>
console.log(`${transport.pid} ${transport.url} ${response.status}`) fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never return yield* Effect.never
}), }),
), ),
+22 -11
View File
@@ -11,9 +11,13 @@ import { join } from "node:path"
// is all a client needs to connect. The daemon's own configuration (port, // is all a client needs to connect. The daemon's own configuration (port,
// persisted password) is CLI-owned and never read here. // persisted password) is CLI-owned and never read here.
export type Transport = { export type Endpoint = {
readonly url: string readonly url: string
readonly headers?: RequestInit["headers"] readonly auth?: {
readonly type: "basic"
readonly username: string
readonly password: string
}
} }
export type Options = { export type Options = {
@@ -31,7 +35,7 @@ export type Options = {
// Read-only lookup: registration file plus health check and version gate. // 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 start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { 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) { const discoverLocal = Effect.fnUntraced(function* (options: Options) {
@@ -72,7 +76,7 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
child.kill("SIGTERM") 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.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
Effect.mapError(() => new Error("Failed to start server")), Effect.mapError(() => new Error("Failed to start server")),
) )
@@ -90,8 +94,9 @@ function fallback() {
return join(state, "opencode", "service.json") return join(state, "opencode", "service.json")
} }
function auth(password: string): RequestInit["headers"] { export function headers(endpoint: Endpoint): RequestInit["headers"] {
return { authorization: "Basic " + btoa("opencode:" + password) } if (endpoint.auth === undefined) return undefined
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
} }
export const Info = Schema.Struct({ export const Info = Schema.Struct({
@@ -120,14 +125,20 @@ const read = Effect.fnUntraced(function* (file?: string) {
type LocalService = { type LocalService = {
readonly info: Info readonly info: Info
readonly transport: Transport readonly endpoint: Endpoint
} }
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { 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(() => const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), { fetch(new URL("/api/health", info.url), {
headers, headers: headers(endpoint),
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}), }),
).pipe(Effect.option, Effect.map(Option.getOrUndefined)) ).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 (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined if (info.version !== undefined && health.value.version !== info.version) return undefined
if (version !== undefined && health.value.version !== 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 ( if (
!allowLegacy || !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)) (typeof body === "object" && body !== null && ("version" in body || "pid" in body))
) )
return undefined 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 // Health-checked lookup without the version gate: lifecycle operations must be
+39 -12
View File
@@ -2,6 +2,8 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
import { registerOpencodeSpinner } from "./component/register-spinner" import { registerOpencodeSpinner } from "./component/register-spinner"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Deferred, Effect } from "effect" 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 { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" 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 { DialogStatus } from "./component/dialog-status"
import { DialogDebug } from "./component/dialog-debug" import { DialogDebug } from "./component/dialog-debug"
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link" import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogThemeList } from "./component/dialog-theme-list" import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help" import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent" import { DialogAgent } from "./component/dialog-agent"
@@ -81,8 +84,6 @@ import {
useOpencodeKeymap, useOpencodeKeymap,
} from "./keymap" } 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 { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention" import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio" import * as TuiAudio from "./audio"
@@ -144,11 +145,11 @@ const appBindingCommands = [
] as const ] as const
export type TuiInput = { export type TuiInput = {
client: OpencodeClient server: {
api: OpenCodeClient endpoint: Service.Endpoint
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> discover?: () => Promise<Service.Endpoint>
reload?: () => Promise<void> reload?: () => Promise<void>
link?: DialogLinkCredentials }
args: Args args: Args
config: TuiConfig.Resolved config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
@@ -191,6 +192,25 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const log = input.log ?? (() => {}) const log = input.log ?? (() => {})
const global = yield* Global.Service 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 exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
const result = yield* Effect.scoped( const result = yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -317,10 +337,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<TuiConfigProvider config={input.config}> <TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider <SDKProvider
client={input.client} client={createOpencodeClient({ ...options, directory })}
api={input.api} api={api}
discover={input.discover} discover={reconnect}
reload={input.reload} reload={input.server.reload}
> >
<PermissionProvider> <PermissionProvider>
<ProjectProvider> <ProjectProvider>
@@ -338,7 +358,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<App <App
onSnapshot={input.onSnapshot} onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost} pluginHost={input.pluginHost}
link={input.link} link={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/> />
</LocationProvider> </LocationProvider>
</EditorContextProvider> </EditorContextProvider>
+20 -16
View File
@@ -5,7 +5,7 @@ import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime" 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 () => { test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) 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 listeners = new Set(process.listeners("SIGHUP"))
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
let started!: () => void let started!: () => void
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
started = resolve started = resolve
@@ -30,8 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app") const { run } = await import("../src/app")
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
client: createClient(calls.fetch), server: { endpoint: { url: server.url.toString() } },
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: {}, args: {},
log: () => {}, 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) expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally { } finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy() if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore() mock.restore()
} }
}) })
@@ -79,22 +80,25 @@ test("session lifecycle updates the terminal title and prints the epilogue after
} }
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { 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") if (url.pathname === "/api/session")
return json({ return json({
data: [ data: [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 },
},
],
cursor: {}, 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) }, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const originalWrite = process.stdout.write.bind(process.stdout) const originalWrite = process.stdout.write.bind(process.stdout)
let stdout = "" let stdout = ""
let api: TuiPluginApi | undefined 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 { run } = await import("../src/app")
const task = Effect.runPromise( const task = Effect.runPromise(
run({ run({
client: createClient(calls.fetch), server: { endpoint: { url: server.url.toString() } },
api: createApi(calls.fetch),
config: createTuiResolvedConfig({ plugin_enabled: {} }), config: createTuiResolvedConfig({ plugin_enabled: {} }),
args: { sessionID: "dummy" }, args: { sessionID: "dummy" },
log: () => {}, log: () => {},
@@ -145,6 +148,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
} finally { } finally {
process.stdout.write = originalWrite process.stdout.write = originalWrite
if (!setup.renderer.isDestroyed) setup.renderer.destroy() if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore() mock.restore()
} }
}) })
+2
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 === "/experimental/capabilities") return json({ backgroundSubagents: true })
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) 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/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/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }]) if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
if (url.pathname === "/api/shell") if (url.pathname === "/api/shell")