diff --git a/docs/design/service-lifecycle.md b/docs/design/service-lifecycle.md index d4506521a9..e32a6cb441 100644 --- a/docs/design/service-lifecycle.md +++ b/docs/design/service-lifecycle.md @@ -30,6 +30,50 @@ This proposal does not introduce a supervisor process, warm candidate server, protocol negotiation, idle background restart, or general execution-recovery framework. +## Architecture at a Glance + +```text + ╭───────────────────╮ + │ CLI ServiceConfig │ + ╰─────────┬─────────╯ + │ + ▼ + ╭──────────────────────╮ + │ CLI ServerConnection │ + ╰───────────┬──────────╯ + ╭──────────────────╰───────────────────╮ + ▼ ▼ +╭──────────────────────────╮ ╭─────────────────────────╮ +│ Client Service lifecycle │ │ CLI runPromiseWith seam │ +╰─────────────┬────────────╯ ╰─────────────┬───────────╯ + ╰─────╮ │ + ▼ ▼ + ╭────────────────────────────╮ ╭─────────────╮ + │ Background service process │ │ TUI / Solid │ + ╰──────────────┬─────────────╯ ╰──────┬──────╯ + │ │ + ╰────────────◀────────────────────╯ + ╭───────────────────────╮ + │ Server HTTP transport │ + ╰───────────┬───────────╯ + │ + ▼ + ╭──────────────────╮ + │ Core application │ + ╰──────────────────╯ +``` + +| Owner | Responsibility | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations | +| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command | +| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects | +| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot | +| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport | +| `packages/core` | Application behavior behind the transport | +| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities | +| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI | + ## Implementation Status | Area | State | @@ -164,19 +208,22 @@ This design gives each concept one authority. ## System Model -```mermaid -flowchart LR - TUI[Fresh or existing TUI] - REG[Registration file] - LOCK[Process-held OS service lock] - SHELL[Lifecycle shell] - APP[OpenCode application] - - TUI -->|discover| REG - TUI -->|observe| SHELL - TUI -->|normal requests| APP - SHELL --> APP - LOCK -->|authorizes one owner| SHELL +```text +╭───────────────────────╮ ╭──────────────────────────────╮ +│ Fresh or existing TUI │ │ Process-held OS service lock │ +╰───────────┬───────────╯ ╰───────────────┬──────────────╯ + ╰─────┬ normal requests observe ───────────────────────╮ │ + │ discover │ ├──╯ authorizes one owner + ▼ │ ▼ + ╭───────────────────╮ │ ╭─────────────────╮ + │ Registration file │ │ │ Lifecycle shell │ + ╰───────────────────╯ │ ╰────────┬────────╯ + │ │ + ├────────────────────────╯ + ▼ + ╭──────────────────────╮ + │ OpenCode application │ + ╰──────────────────────╯ ``` The lifecycle shell and application run in the same process. The distinction is @@ -311,24 +358,39 @@ Windows, where Bun FFI is not available on every shipped architecture. It lives alongside the existing utility in `packages/core/src/util`. This primitive is the foundation of the design, so the delivery sequence spikes it first. -```mermaid -flowchart TD - A[Contender process starts] - B{Acquire service lock?} - C[Exit successfully] - D[Bind lifecycle shell] - E[Write registration] - F[Report starting] - G[Initialize application] - H[Report ready] - I[Serve until shutdown] - - A --> B - B -->|No| C - B -->|Yes| D - D --> E --> F --> G - G -->|Success| H --> I - G -->|Failure| J[Report failed and stay bound] +```text +Contender Lock Lifecycle Application + │ │ │ │ + ├─ try acquire ───▶ │ │ + │ │ │ │ + ╭─ alt: lock held ────────────────────────────────────────────────╮ + │ │ │ │ │ │ + │ ◀─ busy ──────────┤ │ │ │ + │ │ │ │ │ │ + │ ├─────────╮ │ │ │ │ + │ │ exit │ │ │ │ │ + │ ◀─────────╯ │ │ │ │ + │ │ │ │ │ │ + ├─ else: lock acquired ───────────────────────────────────────────┤ + │ │ │ │ │ │ + │ ◀─ owner ─────────┤ │ │ │ + │ │ │ │ │ │ + │ ├─ bind, register, starting ────────▶ │ │ + │ │ │ │ │ │ + │ ├─ initialize ──────────────────────────────────────────────▶ │ + │ │ │ │ │ │ + │╭─ alt: boot succeeds ──────────────────────────────────────────╮│ + ││ │ │ │ │ ││ + ││ │ │ ◀─ ready ───────────────┤ ││ + ││ │ │ │ │ ││ + │├─ else: boot fails ────────────────────────────────────────────┤│ + ││ │ │ │ │ ││ + ││ │ │ ◀─ failed, stay bound ──┤ ││ + ││ │ │ │ │ ││ + │╰───────────────────────────────────────────────────────────────╯│ + │ │ │ │ │ │ + ╰─────────────────────────────────────────────────────────────────╯ + │ │ │ │ ``` Lock acquisition by a contender is nonblocking or tightly bounded. A loser diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 49e367d48e..781af277d7 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -3,7 +3,7 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" -import { Server } from "../../services/server" +import { ServerConnection } from "../../services/server-connection" const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) @@ -18,7 +18,7 @@ type OpenApi = { export default Runtime.handler( Commands.commands.api, Effect.fn("cli.api")(function* (input) { - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, mismatch: "ignore", @@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) { return { method: input[0].toUpperCase(), path: input[1] } } -function resolveRequest( - endpoint: Service.Endpoint, - input: readonly string[], - params: Record, -) { +function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record) { const raw = rawRequest(input) 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")) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 8143c26352..3f187369b5 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Config } from "../../config" -import { Effect, Option } from "effect" -import { Server } from "../../services/server" +import { Context, Effect, FileSystem, Option } from "effect" +import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/core/npm" @@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) => yield* updater.check().pipe(Effect.forkScoped) const preflight = UpdatePreflight.make() yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close())) - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, onStart: (reason, existing) => { @@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) => preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service - const context = yield* Effect.context() + const fileSystem = yield* FileSystem.FileSystem + const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem)) + const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const runPromise = Effect.runPromiseWith(context) + const service = server.service yield* run({ - server, + server: { + endpoint: server.endpoint, + service: service + ? { + reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }), + restart: () => runServicePromise(service.restart()), + } + : undefined, + }, args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) }, config: { path: config.path, diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 7919b2bc8e..e44f7e0b2d 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -1,14 +1,14 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Server } from "../../services/server" +import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.mini, (input) => Effect.gen(function* () { const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini")) yield* Effect.promise(async () => validateMiniTerminal()) const serverURL = Option.getOrUndefined(input.server) - const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone }) + const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone }) yield* Effect.promise(() => runMini({ server, diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index e8abf0cb1e..50fbaa4e2c 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -1,13 +1,13 @@ import { Effect, Option } from "effect" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Server } from "../../services/server" +import { ServerConnection } from "../../services/server-connection" export default Runtime.handler(Commands.commands.run, (input) => Effect.gen(function* () { const { runNonInteractive } = yield* Effect.promise(() => import("../../mini")) const separator = process.argv.indexOf("--", 2) - const server = yield* Server.resolve({ + const server = yield* ServerConnection.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, }) diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 521995b45c..ccbb900ba0 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,12 +1,12 @@ import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { Server } from "../services/server" +import { ServerConnection } from "../services/server-connection" import { waitForCatalogReady } from "./catalog.shared" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin" import type { RunInput, RunTuiConfig } from "./types" export type MiniCommandInput = { - server: Server.Resolved + server: ServerConnection.Resolved continue?: boolean session?: string fork?: boolean @@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) { return agentTask } const resolveSession = async () => { - const [agent, selected] = await Promise.all([ - resolveAgent(), - selectSession(sdk, directory, input), - ]) + const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)]) const readyModel = model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined) if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel }) diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index 2fc849bce0..ad95f52f84 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -4,7 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Model } from "@opencode-ai/schema/model" import { open } from "node:fs/promises" import path from "node:path" -import { Server } from "../services/server" +import { ServerConnection } from "../services/server-connection" import { loadRunAgents, waitForCatalogReady } from "./catalog.shared" import { runNonInteractivePrompt } from "./noninteractive" import { toolInlineInfo } from "./tool" @@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types" import { UI } from "./ui" export type RunCommandInput = { - server: Server.Resolved + server: ServerConnection.Resolved message: string[] continue?: boolean session?: string @@ -73,8 +73,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) - if (variant && !model) - return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) diff --git a/packages/cli/src/services/server.ts b/packages/cli/src/services/server-connection.ts similarity index 77% rename from packages/cli/src/services/server.ts rename to packages/cli/src/services/server-connection.ts index b1cdce4cbb..9a4bbcc39c 100644 --- a/packages/cli/src/services/server.ts +++ b/packages/cli/src/services/server-connection.ts @@ -1,4 +1,3 @@ -import { NodeFileSystem } from "@effect/platform-node" import { Service } from "@opencode-ai/client/effect" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -16,11 +15,10 @@ export type Args = { export type Resolved = { readonly endpoint: Service.Endpoint - readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise - readonly reload?: () => Promise + readonly service?: ReturnType } -export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { +export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) { if (args.server !== undefined && args.standalone) return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) if (args.server !== undefined) { @@ -45,24 +43,24 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) { } const options = yield* ServiceConfig.options() - const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace") - const reconnectOptions = { ...options, version: undefined } return { - endpoint, - reconnect: (onStatus, signal) => - Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), { - signal, - }), - reload: () => - Effect.runPromise( - Effect.gen(function* () { - yield* Service.stop(options, { targetVersion: options.version }) - yield* Service.start(options) - }).pipe(Effect.provide(NodeFileSystem.layer)), - ), + endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"), + service: managedService(options), } satisfies Resolved }) +function managedService(options: Service.StartOptions) { + const reconnectOptions = { ...options, version: undefined } + return { + reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }), + restart: () => + Effect.gen(function* () { + yield* Service.stop(options, { targetVersion: options.version }) + yield* Service.start(options) + }), + } +} + const resolveManaged = Effect.fnUntraced(function* ( options: Service.StartOptions, mismatch: NonNullable, @@ -92,4 +90,4 @@ function connectError(endpoint: Service.Endpoint, cause: unknown) { return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause }) } -export * as Server from "./server" +export * as ServerConnection from "./server-connection" diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts new file mode 100644 index 0000000000..3dd68e2bc9 --- /dev/null +++ b/packages/cli/test/server-connection.test.ts @@ -0,0 +1,59 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { expect, test } from "bun:test" +import { Effect, FileSystem, Scope } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { ServerConnection } from "../src/services/server-connection" +import { ServiceConfig } from "../src/services/service-config" + +test("resolution groups Effect-native lifecycle operations only for the managed service", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-")) + const id = "server-resolution-test" + const server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + healthy: true, + version: InstallationVersion, + pid: process.pid, + instanceID: id, + status: { type: "ready" }, + }) + }, + }) + const registration = path.join(root, "state", ServiceConfig.filename()) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const runPromise = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped)) + + try { + await fs.mkdir(path.dirname(registration), { recursive: true }) + await fs.writeFile( + registration, + JSON.stringify({ + id, + version: InstallationVersion, + url: server.url.toString(), + pid: process.pid, + }), + ) + const resolved = await runPromise(ServerConnection.resolve({})) + + expect(resolved.endpoint.url).toBe(server.url.toString()) + expect(resolved.service).toBeDefined() + if (!resolved.service) throw new Error("Expected managed service capabilities") + expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true) + expect(Effect.isEffect(resolved.service.restart())).toBe(true) + expect(await runPromise(resolved.service.reconnect(() => {}))).toEqual(resolved.endpoint) + + const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() })) + expect(explicit.endpoint.url).toBe(server.url.toString()) + expect(explicit.service).toBeUndefined() + } finally { + await server.stop(true) + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/docs/troubleshooting.mdx b/packages/docs/troubleshooting.mdx index 57856c9d71..f0fe58cee9 100644 --- a/packages/docs/troubleshooting.mdx +++ b/packages/docs/troubleshooting.mdx @@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues." --- - You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the - steps below, inspect its service and logs, and help identify the issue. + You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read + the steps below, inspect its service and logs, and help identify the issue. OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and @@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it: opencode2 service restart ``` -From inside the TUI, run `/reload` to restart the managed service and reconnect: +From inside the TUI, run `/restart` to restart the managed service and reconnect: ```text -/reload +/restart ``` You can also stop and start it explicitly: @@ -45,8 +45,8 @@ opencode2 service start ``` - OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed - when diagnosing its lifecycle. + OpenCode normally discovers or starts the shared background service automatically. The service commands are only + needed when diagnosing its lifecycle. ## Run an isolated session @@ -125,8 +125,8 @@ The database normally lives at: `OPENCODE_DB` can override the database location. - Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, - and make a backup before inspecting persistent data with external tools. + Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the + daemon, and make a backup before inspecting persistent data with external tools. ## Explicit servers diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 452b1242bf..4e45e66312 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -118,6 +118,7 @@ const appBindingCommands = [ "provider.connect", "opencode.status", "server.pair", + "service.restart", "opencode.debug", "theme.switch", "theme.switch_mode", @@ -138,8 +139,10 @@ const appBindingCommands = [ export type TuiInput = { server: { endpoint: Service.Endpoint - reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise - reload?: () => Promise + service?: { + reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise + restart: () => Promise + } } args: Args config: Config.Interface @@ -183,14 +186,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), ) const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined - const reconnectEndpoint = input.server.reconnect - const reconnect = reconnectEndpoint - ? async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => { - const endpoint = await reconnectEndpoint(onStatus, signal) - const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } - return { - api: OpenCode.make(next), - } + const managed = input.server.service + const service = managed + ? { + reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => { + const endpoint = await managed.reconnect(onStatus, signal) + const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } + return { api: OpenCode.make(next) } + }, + restart: managed.restart, } : undefined const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } @@ -324,7 +328,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } > - + @@ -757,19 +761,21 @@ function App(props: { pair?: DialogPairCredentials }) { }, category: "System", }, - ...(client.reload + ...(client.restart ? [ { - name: "server.reload", - title: "Reload server", - slash: { name: "reload" }, + name: "service.restart", + title: "Restart service", + slash: { name: "restart" }, run: async () => { + const restart = client.restart + if (!restart) return dialog.clear() - toast.show({ variant: "info", message: "Reloading server...", duration: 30000 }) - // reload resolves once the replacement service is healthy; the + toast.show({ variant: "info", message: "Restarting service...", duration: 30000 }) + // restart resolves once the replacement service is healthy; the // event stream reattaches through the reconnect loop. - await client.reload!() - .then(() => toast.show({ variant: "success", message: "Server reloaded" })) + await restart() + .then(() => toast.show({ variant: "success", message: "Service restarted" })) .catch(toast.error) }, category: "System", diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index 6994f6a6f7..1c9d7e8d24 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -18,18 +18,18 @@ export type ClientConnectionEvent = { } } +type ManagedService = { + reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }> + restart: () => Promise +} + type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract } const connectTimeout = 2_000 const connectionHistoryLimit = 50 export const { use: useClient, provider: ClientProvider } = createSimpleContext({ name: "Client", - init: (props: { - api: OpenCodeClient - reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }> - // Stops and starts the managed service; present only in service mode. - reload?: () => Promise - }) => { + init: (props: { api: OpenCodeClient; service?: ManagedService }) => { const log = useLog({ component: "client" }) const abort = new AbortController() const history: ClientConnectionEvent[] = [] @@ -115,8 +115,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( // Re-resolve the transport before retrying: the server may have // moved (service restarted on a new port) or need starting. Static // transports (--server, standalone) resolve to the same address. - if (props.reconnect) { - const next = await props.reconnect(setService, controller.signal).catch((error) => { + if (props.service) { + const next = await props.service.reconnect(setService, controller.signal).catch((error) => { if (!controller.signal.aborted) log.info("server resolution failed", { attempt, @@ -168,7 +168,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( }, }, }, - reload: props.reload, + restart: props.service?.restart, } }, }) diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 42949ce5d0..c14f0e1e33 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -65,10 +65,11 @@ async function mount( const ready = new Promise((resolve) => { done = resolve }) + const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined const app = await testRender(() => ( - + { client = ctx.client diff --git a/packages/www/content/docs/(docs)/troubleshooting.mdx b/packages/www/content/docs/(docs)/troubleshooting.mdx index dd3b4362fc..9696c1fdf0 100644 --- a/packages/www/content/docs/(docs)/troubleshooting.mdx +++ b/packages/www/content/docs/(docs)/troubleshooting.mdx @@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues." --- - You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the - steps below, inspect its service and logs, and help identify the issue. + You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read + the steps below, inspect its service and logs, and help identify the issue. OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and @@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it: opencode2 service restart ``` -From inside the TUI, run `/reload` to restart the managed service and reconnect: +From inside the TUI, run `/restart` to restart the managed service and reconnect: ```text -/reload +/restart ``` You can also stop and start it explicitly: @@ -45,8 +45,8 @@ opencode2 service start ``` - OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed - when diagnosing its lifecycle. + OpenCode normally discovers or starts the shared background service automatically. The service commands are only + needed when diagnosing its lifecycle. ## Run an isolated session @@ -125,8 +125,8 @@ The database normally lives at: `OPENCODE_DB` can override the database location. - Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon, - and make a backup before inspecting persistent data with external tools. + Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the + daemon, and make a backup before inspecting persistent data with external tools. ## Explicit servers