Răsfoiți Sursa

refactor(cli): define server connection boundary (#37133)

Kit Langton 1 lună în urmă
părinte
comite
f5dd181443

+ 93 - 31
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

+ 3 - 7
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<string, string>,
-) {
+function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
   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"))

+ 16 - 5
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<FileSystem.FileSystem>()
     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,

+ 2 - 2
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,

+ 2 - 2
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,
     })

+ 3 - 6
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 })

+ 3 - 4
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 } })

+ 17 - 19
packages/cli/src/services/server.ts → 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<Service.Endpoint>
-  readonly reload?: () => Promise<void>
+  readonly service?: ReturnType<typeof managedService>
 }
 
-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<Args["mismatch"]>,
@@ -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"

+ 59 - 0
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 = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
+    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 })
+  }
+})

+ 8 - 8
packages/docs/troubleshooting.mdx

@@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
 ---
 
 <Tip>
-  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.
 </Tip>
 
 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
 ```
 
 <Note>
-  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.
 </Note>
 
 ## Run an isolated session
@@ -125,8 +125,8 @@ The database normally lives at:
 `OPENCODE_DB` can override the database location.
 
 <Warning>
-  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.
 </Warning>
 
 ## Explicit servers

+ 25 - 19
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<Service.Endpoint>
-    reload?: () => Promise<void>
+    service?: {
+      reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
+      restart: () => Promise<void>
+    }
   }
   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) {
                                         }
                                       >
                                         <PluginRuntimeProvider value={pluginRuntime}>
-                                          <ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
+                                          <ClientProvider api={api} service={service}>
                                             <PermissionProvider>
                                               <DataProvider>
                                                 <LocationProvider>
@@ -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",

+ 9 - 9
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<void>
+}
+
 type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
 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<void>
-  }) => {
+  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,
     }
   },
 })

+ 2 - 1
packages/tui/test/cli/tui/use-event.test.tsx

@@ -65,10 +65,11 @@ async function mount(
   const ready = new Promise<void>((resolve) => {
     done = resolve
   })
+  const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined
 
   const app = await testRender(() => (
     <TestTuiContexts log={log}>
-      <ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}>
+      <ClientProvider api={createApi(calls.fetch)} service={service}>
         <Probe
           onReady={(ctx) => {
             client = ctx.client

+ 8 - 8
packages/www/content/docs/(docs)/troubleshooting.mdx

@@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
 ---
 
 <Tip>
-  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.
 </Tip>
 
 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
 ```
 
 <Note>
-  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.
 </Note>
 
 ## Run an isolated session
@@ -125,8 +125,8 @@ The database normally lives at:
 `OPENCODE_DB` can override the database location.
 
 <Warning>
-  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.
 </Warning>
 
 ## Explicit servers