diff --git a/packages/opencode/specs/simulation/simulated-network-llm.md b/packages/opencode/specs/simulation/simulated-network-llm.md index c7631cf4cbbf..f09685b26aca 100644 --- a/packages/opencode/specs/simulation/simulated-network-llm.md +++ b/packages/opencode/specs/simulation/simulated-network-llm.md @@ -65,7 +65,7 @@ Exchange = { id, body, queue: Queue, deferred lifecycle } ### 4. Backend control WebSocket (simulation-gated) -Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. +Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): @@ -101,8 +101,8 @@ Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not ye A driver manages two loopback WebSocket connections: -- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. -- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. +- TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace. +- Backend control server (manifest `endpoints.backend`) — LLM exchanges, network log. Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. @@ -117,7 +117,7 @@ The driver-facing model must be selectable in the TUI. Simulation seeds config ( ## End-to-end flow ``` -driver TUI sim server (40900+) backend + control WS (40950+) +driver TUI drive server backend + drive WS | | | |-- ui.action (submit) ----->| | | |-- (normal app HTTP) ---->| session runner starts diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index ebc4bd3743d6..c8a470a44975 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -12,11 +12,11 @@ This phase proves the core shape without swapping every foundational layer yet. Implementation checklist: -- [x] Add `OPENCODE_SIMULATION=1` activation in V1/full-TUI startup. +- [x] Add `OPENCODE_DRIVE=` activation in V1/full-TUI startup. - [x] Add simulation trace service with in-memory append-only records. - [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. - [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. -- [x] Add reusable JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint. - [x] Expose `ui.state`, `ui.action`, `ui.render`. - [x] Expose `trace.list`, `trace.clear`, `trace.export`. - [x] Wire visible V1/full-TUI renderer path through the same action protocol. @@ -24,8 +24,8 @@ Implementation checklist: Scope: -- Add `OPENCODE_SIMULATION=1` activation. -- Start a TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- Add `OPENCODE_DRIVE=` activation. +- Start a TUI-owned JSON-RPC WebSocket server at the manifest's UI endpoint. - Expose `ui.state`, `ui.action`, `ui.render`. - Use the old simulation action model: type text, press keys, press enter, arrows, focus, click. - Support fake OpenTUI renderer and visible renderer through the same action protocol. @@ -34,7 +34,7 @@ Scope: Done when: -- `OPENCODE_SIMULATION=1 bun run dev` starts the normal app. +- `OPENCODE_DRIVE= bun run dev` starts the normal app and UI drive server. - A local driver can connect to the WebSocket. - The driver can inspect current screen/elements/actions. - The driver can execute real TUI inputs. @@ -54,19 +54,19 @@ Goal: make the app safe and controlled by swapping the lowest layers, not app lo Implementation checklist: - [x] Add `packages/simulation/src/backend` as the home for backend simulation layer replacements, exported from `backend/index.ts` as `simulationReplacements`; `@opencode-ai/simulation` is private/non-published and depends on logic/framework packages (`core`, `llm`, `effect`, OpenTUI), while `server` and `tui` consume it. -- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("@opencode-ai/simulation/backend")` gated on `OPENCODE_SIMULATE`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. - [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. -- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Root the fake filesystem at `process.cwd()` at layer-build time. The anchor is a real, empty host directory the runner creates and cds into. - [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. - [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. - [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". -- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. -- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. -- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATION_ROOT/STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). +- [x] Add snapshot seeding from `OPENCODE_SIMULATE_STATE`: `files/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATE=1` + `OPENCODE_SIMULATE_STATE` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. +- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner; a full run needs `OPENCODE_SIMULATE_STATE`, `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`, and `XDG_*_HOME` pointed into the anchor, plus Bun's `--preload=@opentui/solid/preload` when launched outside `packages/cli`). - [ ] Assert the anchor directory is still empty at the end of the run (KV/log/flock still write through real XDG paths; they are contained in the anchor by the env seams but not yet in-memory). - [x] Add simulated network registry (`packages/simulation/src/backend/network.ts`): replaces the `httpClient` platform node, resolves all outbound HTTP against an in-memory route table, denies unknown destinations loudly, and keeps a bounded request log (design: `simulated-network-llm.md`). - [x] Add driver-answered LLM as an OpenAI route in the simulated network (`openai.ts` + `llm-exchange.ts`): provider requests open exchanges; the driver streams chunks back which are encoded as real OpenAI Chat SSE (schema-checked against `OpenAIChatEvent`) and consumed by the real protocol pipeline. No enqueue store — the driver is the model. -- [x] Add backend-hosted simulation control WebSocket (`control.ts`): JSON-RPC on `127.0.0.1:40950+`, started when the simulation module loads. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage two sockets: TUI control (40900+) for UI, backend control (40950+) for LLM/network. +- [x] Add backend-hosted drive control WebSocket (`control.ts`): JSON-RPC at the named manifest's backend endpoint, started when `OPENCODE_DRIVE` is set. Drivers connect directly (standalone topology — no frontend proxy): `llm.attach` (replays pending exchanges), `llm.chunk`, `llm.finish`, `llm.pending`, `network.log`; `llm.request` notifications push opened exchanges. This is also the headless-simulation interface. Drivers manage the manifest's UI endpoint for UI control and backend endpoint for LLM/network control. - [x] Answer `https://models.dev/api.json` with an empty catalog in the simulated network; providers come from seeded config (`opencode.json` in the snapshot defines an openai-compatible provider with a dummy `apiKey`, which passes the catalog availability gate and resolves onto the real openai-chat route). - [x] Fix `buildLocationServiceMap` to apply replacements when compiling hoisted global nodes; platform-node replacements (filesystem, httpClient) were silently ignored inside hoisted globals. - [x] Verify end to end headless (real route stack in-process + backend control WS: prompt -> `llm.request` -> driver chunks -> assistant message contains driver text; script: `packages/server/script/e2e-sim.ts`) and through the TUI (fake renderer, both sockets: type + submit via TUI WS, answer `llm.request` via backend WS, assistant reply rendered on screen; script: `packages/tui/script/sim-llm-driver.ts`). @@ -78,7 +78,7 @@ Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. - Create a real, empty anchor directory (`mkdtemp`) and `process.chdir` into it before any command resolves its working directory; skip creation when the runner already spawned the app inside an anchor. - Root the in-memory filesystem at `process.cwd()` (the anchor). No cwd monkey-patching: cwd, `$PWD`, and `path.resolve()` stay truthful. -- Add snapshot loading from `OPENCODE_SIMULATION_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `project/` paths joined onto the anchor root), config, env, and optional LLM/network state from it. +- Add snapshot loading from `OPENCODE_SIMULATE_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `files/` paths joined onto the anchor root), config, env, and optional LLM/network state from it. - Route config/data/state/cache/temp paths into the simulated space using existing env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`), set before `packages/core/src/global.ts` import-time path setup runs. - Deny host filesystem escapes loudly (paths outside the anchor root fail with typed simulation errors). - Assert the anchor directory on the host is still empty at the end of the run; anything written there means a code path bypassed the simulated filesystem. @@ -97,7 +97,7 @@ Done when: - Unknown network fails with a simulation error. - Host filesystem escape fails with a simulation error. - The anchor directory on the host is empty after a run. -- The app boots from a snapshot directory via `OPENCODE_SIMULATION_STATE` and observes the seeded project files, config, and env through normal app paths. +- The app boots from a snapshot directory via `OPENCODE_SIMULATE_STATE` and observes the seeded project files, config, and env through normal app paths. - A driver can seed a project filesystem. - A driver can enqueue an LLM script and submit a prompt through the TUI. - The real session/tool path consumes the scripted LLM behavior. diff --git a/packages/opencode/specs/simulation/simulation.md b/packages/opencode/specs/simulation/simulation.md index 5478796e78fe..7682fad0c4eb 100644 --- a/packages/opencode/specs/simulation/simulation.md +++ b/packages/opencode/specs/simulation/simulation.md @@ -11,7 +11,7 @@ The first milestone is an interactive exploration and model-based testing enviro This is not intended to be a custom simulated app or a separate `simulate` command. The normal app should run, with simulation enabled by one required flag: ```sh -OPENCODE_SIMULATION=1 bun run dev +OPENCODE_SIMULATE=1 bun run dev ``` ## Non-Goals @@ -21,7 +21,7 @@ OPENCODE_SIMULATION=1 bun run dev - Do not build shrinking in the first milestone. - Do not make generated randomized runs part of CI yet. - Do not build differential testing in the first milestone. -- Do not expose simulation controls when `OPENCODE_SIMULATION` is not set. +- Do not expose drive controls when `OPENCODE_DRIVE` is not set. ## Design Principles @@ -37,12 +37,12 @@ OPENCODE_SIMULATION=1 bun run dev ## Activation -`OPENCODE_SIMULATION=1` is the only required flag. +`OPENCODE_SIMULATE=1` swaps the backend's foundational layers for simulated implementations. `OPENCODE_DRIVE=` independently starts the frontend and backend control WebSockets using the exact endpoints from the named opencode-drive registry manifest. `OPENCODE_DRIVE=1` starts an unnamed instance at `ws://127.0.0.1:40900` for the UI and `ws://127.0.0.1:40950` for the backend. Initial state is provided through an optional snapshot directory: ```sh -OPENCODE_SIMULATION=1 OPENCODE_SIMULATION_STATE=/path/to/snapshot bun run dev +OPENCODE_SIMULATE=1 OPENCODE_DRIVE=demo OPENCODE_SIMULATE_STATE=/path/to/snapshot bun run dev ``` Optional flags can be added later, but should stay minimal. Reasonable optional parameters later include renderer mode, trace output path, seed, or port override. @@ -54,15 +54,15 @@ When enabled: - The app creates and changes into a real, empty anchor directory (see Filesystem). - The app reads the snapshot directory, if provided, and seeds all simulated state from it. - The app builds with simulation layer replacements. -- The TUI process starts a loopback WebSocket control server. +- The TUI and backend processes start loopback WebSocket control servers when `OPENCODE_DRIVE` is set. - Simulation-gated backend control routes become available only to the frontend/control path. - In-memory trace recording starts automatically. Path seams reuse existing environment variables where they already exist: `OPENCODE_CONFIG_DIR` for global config, `OPENCODE_TEST_HOME` for home, and `OPENCODE_DB=:memory:` for the database. Simulation mode should set these before foundational modules load rather than inventing parallel mechanisms. -## Control Server +## Control Servers -The external control surface lives in the TUI/frontend process, not the backend API server. +The UI control surface lives in the TUI/frontend process. A separate backend control surface handles simulated LLM and network operations. This is important because the frontend has direct access to the renderer, screen state, focus state, interactable elements, and user input APIs. The backend remains the normal backend, with only simulation-gated control routes used internally by the frontend when needed. @@ -70,9 +70,10 @@ Protocol: - JSON-RPC 2.0 over WebSocket. - Loopback only. -- Start at `127.0.0.1:40900`. -- If occupied, scan upward and report the actual URL. -- External drivers connect only to this frontend WebSocket. +- `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints. +- The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints. +- Startup fails rather than scanning when either manifest endpoint is unavailable. +- External drivers connect to both WebSockets when they need UI and backend controls. The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. @@ -129,7 +130,7 @@ Both fake OpenTUI renderer and visible terminal renderer should share this proto The backend server should be exactly the normal backend server. -Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATION=1`. They are private implementation details for the frontend simulation server to proxy commands like filesystem seeding, LLM scripting, network registration, and snapshots. +Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATE=1`. They are private implementation details for commands like filesystem seeding, LLM scripting, network registration, and snapshots. External drivers should not use backend simulation routes directly. @@ -197,13 +198,13 @@ The anchor may be created by the app itself at activation, or by an external run ## Initial State Snapshot -`OPENCODE_SIMULATION_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input. +`OPENCODE_SIMULATE_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input. Proposed layout: ```text snapshot/ - project/... # workspace files, seeded into the in-memory FS under the anchor root + files/... # workspace files, seeded into the in-memory FS under the anchor root config/opencode.json # global config; the directory backs OPENCODE_CONFIG_DIR env.json # extra environment values to apply llm/... # scripted LLM behavior to pre-enqueue (optional) @@ -212,8 +213,8 @@ snapshot/ Rules: -- Paths inside `project/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor. -- Anything the config references (skills, instructions, reference paths) must exist inside `project/`. A snapshot that references missing files is invalid. +- Paths inside `files/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor. +- Anything the config references (skills, instructions, reference paths) must exist inside `files/`. A snapshot that references missing files is invalid. - The snapshot directory format is the contract between external state generators and the app. Generators (such as the opencode-probe project) produce snapshot directories plus a derived expected model; the app consumes only the snapshot. - Seeding through the control server (`backend.filesystem.seed` and friends) remains available for incremental changes during a run; the snapshot covers initial state. @@ -443,8 +444,8 @@ More advanced model/refinement, metamorphic, and differential properties are fut The first major demo should show this system as a real environment for exploring the app in controlled states: -1. Start opencode normally with `OPENCODE_SIMULATION=1`. -2. TUI starts and exposes the simulation WebSocket on `127.0.0.1:40900+`. +1. Start opencode normally with `OPENCODE_SIMULATE=1` and `OPENCODE_DRIVE=`. +2. TUI and backend start their drive WebSockets at the named manifest endpoints. 3. External runner connects. 4. Runner provides a snapshot directory (or seeds the in-memory project filesystem through the control server). 5. Runner generates and enables plugin-provided config state. @@ -459,10 +460,11 @@ The first major demo should show this system as a real environment for exploring ## Done-When Checklist -- `OPENCODE_SIMULATION=1` starts the normal app with simulation wiring. +- `OPENCODE_SIMULATE=1` starts the normal app with simulation wiring. +- `OPENCODE_DRIVE=` starts both drive WebSockets at the manifest endpoints. - Simulation code is isolated under a dedicated simulation/testing area. - App changes outside simulation are limited to activation hooks, builder replacements, TUI startup, and gated backend routes. -- TUI exposes JSON-RPC WebSocket on `127.0.0.1:40900+`. +- TUI and backend expose JSON-RPC WebSockets at the manifest endpoints. - Driver can call `ui.state`. - Driver can execute generated UI actions. - Fake and visible renderer paths use the same action protocol. diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 606fde0b840b..1c5a57f62ff6 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -70,15 +70,17 @@ function makeRoutes( [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ] - // Simulation replacements are loaded via dynamic import so the simulation - // module is never eagerly loaded. Layer.unwrap defers both the import and - // the app-node build to layer-build time; when simulation is off the branch - // is byte-for-byte identical to a plain AppNodeBuilder.build call. - const serviceLayer = simulationEnabled() + const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) - return AppNodeBuilder.build(applicationServices, [...replacements, ...simulationReplacements]) + const { simulationReplacements, startDriveServer } = yield* Effect.promise(() => + import("@opencode-ai/simulation/backend"), + ) + if (driveEnabled()) startDriveServer() + return AppNodeBuilder.build(applicationServices, [ + ...replacements, + ...(simulateEnabled() ? simulationReplacements : []), + ]) }), ) : AppNodeBuilder.build(applicationServices, replacements) @@ -96,8 +98,12 @@ function makeRoutes( ) } -function simulationEnabled() { - return !!process.env.OPENCODE_SIMULATION +function simulateEnabled() { + return !!process.env.OPENCODE_SIMULATE +} + +function driveEnabled() { + return !!process.env.OPENCODE_DRIVE } export const routes = createRoutes() diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index 28bb18c00902..e52c8296f64c 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -22,9 +22,6 @@ import { SimulationNetwork } from "./network" * - `network.log` simulated network request log */ -const DefaultPort = 40950 -const MaxPortAttempts = 100 - type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> function parseRequest(input: string | Buffer) { @@ -68,46 +65,35 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc throw new Error(`Unknown simulation control method: ${request.method}`) } -function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> { - try { - return Bun.serve<{ unsubscribe?: () => void }>({ - hostname: "127.0.0.1", - port, - fetch(request, server) { - if (server.upgrade(request, { data: {} })) return undefined - return new Response("opencode simulation control websocket", { status: 426 }) +export function start(endpoint: string) { + const url = new URL(endpoint) + const server = Bun.serve<{ unsubscribe?: () => void }>({ + hostname: url.hostname, + port: Number(url.port), + fetch(request, server) { + if (server.upgrade(request, { data: {} })) return undefined + return new Response("opencode drive backend websocket", { status: 426 }) + }, + websocket: { + close(socket) { + socket.data.unsubscribe?.() }, - websocket: { - close(socket) { - socket.data.unsubscribe?.() - }, - async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined - try { - request = parseRequest(message) - const result = await handle(socket, request) - const response = SimulationProtocol.JsonRpc.success(request.id, result) - if (response) socket.send(JSON.stringify(response)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, + async message(socket, message) { + let request: SimulationProtocol.JsonRpc.Request | undefined + try { + request = parseRequest(message) + const result = await handle(socket, request) + const response = SimulationProtocol.JsonRpc.success(request.id, result) + if (response) socket.send(JSON.stringify(response)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } }, - }) - } catch (error) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() - const unavailable = message.includes("eaddrinuse") || message.includes("in use") - if (!unavailable || attempts <= 1 || port >= 65535) throw error - return serve(port + 1, attempts - 1) - } -} - -export function start() { - const server = serve() - const url = `ws://${server.hostname}:${server.port}` - process.stderr.write(`opencode simulation backend control websocket: ${url}\n`) + }, + }) + process.stderr.write(`opencode drive backend websocket: ${endpoint}\n`) return { - url, + url: endpoint, stop: () => { server.stop(true) }, diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts index 01a7b003291e..8405198b8767 100644 --- a/packages/simulation/src/backend/filesystem.ts +++ b/packages/simulation/src/backend/filesystem.ts @@ -328,31 +328,31 @@ export function make(options: Options): FileSystem.FileSystem { * Lazily constructed layer so the root defaults to `process.cwd()` at * layer-build time (the simulation anchor directory), not at import time. * - * When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its - * `project/` contents are read from the host once at build time and seeded + * When `OPENCODE_SIMULATE_STATE` points at a snapshot directory, its + * `files/` contents are read from the host once at build time and seeded * into the in-memory tree, joined onto the anchor root. */ export const layer = (options?: Partial) => Layer.sync(FileSystem.FileSystem)(() => make({ root: options?.root ?? process.cwd(), - files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, + files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATE_STATE), ...options?.files }, }), ) function loadSnapshotFiles(stateDirectory: string | undefined) { if (!stateDirectory) return {} - const project = path.join(stateDirectory, "project") - if (!nodeFs.existsSync(project)) return {} + const snapshot = path.join(stateDirectory, "files") + if (!nodeFs.existsSync(snapshot)) return {} const files: Record = {} const walk = (dir: string) => { for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name) if (entry.isDirectory()) walk(file) - if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file)) + if (entry.isFile()) files[path.relative(snapshot, file)] = new Uint8Array(nodeFs.readFileSync(file)) } } - walk(project) + walk(snapshot) return files } diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index dd735d40ddf3..8610867a7a36 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,6 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" +import { DriveManifest } from "../manifest" import { SimulationControl } from "./control" import { SimulationFileSystem } from "./filesystem" import { SimulationFSUtil } from "./fs-util" @@ -10,19 +11,15 @@ import { SimulationOpenAI } from "./openai" /** * Layer replacements applied when the server is built in simulation mode. * - * The server merges these into the app node build when `OPENCODE_SIMULATION` + * The server merges these into the app node build when `OPENCODE_SIMULATE` * is enabled, via a dynamic import so this module is never loaded eagerly. * - * - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real, - * empty anchor directory the runner created and chdir'd into). Everything - * under the root lives in memory; paths outside it fail loudly. + * - Filesystem: in-memory tree rooted at the current working directory. + * Everything under the root lives in memory; paths outside it fail loudly. * - Network: all outbound HTTP resolves against the simulated route table; * unknown destinations are denied. The driver-answered OpenAI endpoint is * registered here as the first route. * - * Loading this module also starts the backend simulation control WebSocket, - * which drivers connect to directly for LLM exchange control and network - * inspection (standalone topology; also the headless-simulation interface). */ SimulationNetwork.register(SimulationOpenAI.route) @@ -30,10 +27,12 @@ SimulationNetwork.register(SimulationOpenAI.route) // an empty catalog; providers come from seeded config instead. SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {})) -SimulationControl.start() +export function startDriveServer() { + return SimulationControl.start(DriveManifest.resolve().endpoints.backend) +} export const simulationReplacements: LayerNode.Replacements = [ - [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], + [filesystem, SimulationFileSystem.layer()], [FSUtil.node, SimulationFSUtil.node], [httpClient, SimulationNetwork.layer], ] diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 03e182cadcd7..168a8999b625 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -12,8 +12,8 @@ const setups = new WeakMap() export async function create(options: CliRendererConfig): Promise { const setup = await createTestRenderer({ ...options, - width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100, - height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40, + width: 100, + height: 40, }) setups.set(setup.renderer, setup) return setup.renderer diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index c6a6c8784c4a..854b5e9846f7 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -2,23 +2,11 @@ import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" import { SimulationTrace } from "./trace" -const DefaultPort = 40900 -const MaxPortAttempts = 100 - export interface Server { readonly url: string readonly stop: () => void } -function isEnabled() { - return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true" -} - -function isPortUnavailable(error: unknown) { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() - return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use") -} - function actionParam(params: unknown) { return SimulationProtocol.Frontend.decodeActionParams(params).action } @@ -53,54 +41,40 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ throw new Error(`Unknown simulation method: ${request.method}`) } -function serve( - harness: Harness, - port = DefaultPort, - attempts = MaxPortAttempts, -): Bun.Server<{ readonly simulation: true }> { - try { - return Bun.serve<{ readonly simulation: true }>({ - hostname: "127.0.0.1", - port, - fetch(request, server) { - if (server.upgrade(request, { data: { simulation: true } })) return undefined - return new Response("opencode simulation websocket", { status: 426 }) +export function start(harness: Harness, endpoint: string): Server { + const url = new URL(endpoint) + const server = Bun.serve<{ readonly drive: true }>({ + hostname: url.hostname, + port: Number(url.port), + fetch(request, server) { + if (server.upgrade(request, { data: { drive: true } })) return undefined + return new Response("opencode drive ui websocket", { status: 426 }) + }, + websocket: { + open() { + SimulationTrace.add("control.connect") }, - websocket: { - open() { - SimulationTrace.add("control.connect") - }, - close() { - SimulationTrace.add("control.disconnect") - }, - async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined - try { - request = parseRequest(message) - const result = await handle(harness, request) - const next = SimulationProtocol.JsonRpc.success(request.id, result) - if (next) socket.send(JSON.stringify(next)) - } catch (error) { - socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) - } - }, + close() { + SimulationTrace.add("control.disconnect") }, - }) - } catch (error) { - if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error - return serve(harness, port + 1, attempts - 1) - } -} - -export function start(harness: Harness): Server | undefined { - if (!isEnabled()) return - const server = serve(harness) - const url = `ws://${server.hostname}:${server.port}` - SimulationTrace.add("control.start", { url }) + async message(socket, message) { + let request: SimulationProtocol.JsonRpc.Request | undefined + try { + request = parseRequest(message) + const result = await handle(harness, request) + const next = SimulationProtocol.JsonRpc.success(request.id, result) + if (next) socket.send(JSON.stringify(next)) + } catch (error) { + socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error))) + } + }, + }, + }) + SimulationTrace.add("control.start", { url: endpoint }) return { - url, + url: endpoint, stop: () => { - SimulationTrace.add("control.stop", { url }) + SimulationTrace.add("control.stop", { url: endpoint }) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index ff24e0459d8f..96e68e8dca15 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -1,27 +1,29 @@ import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core" +import { DriveManifest } from "../manifest" import { SimulationActions } from "./actions" import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" /** - * Simulation-mode renderer entry point. + * Drive-mode renderer entry point. * - * Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the - * normal visible renderer otherwise) and starts the simulation control + * Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal + * visible renderer otherwise) and starts the UI control * server against it. The server stops when the renderer is destroyed, so the * caller only manages the renderer lifecycle. */ -export async function createSimulation(options: CliRendererConfig): Promise { +export async function create(options: CliRendererConfig): Promise { const renderer = - process.env.OPENCODE_SIMULATION_RENDERER === "fake" + process.env.OPENCODE_DRIVE_RENDERER === "fake" ? await SimulationRenderer.create(options) : await createCliRenderer(options) - const server = SimulationServer.start(SimulationActions.createHarness(renderer)) - if (server) { - process.stderr.write(`opencode simulation websocket: ${server.url}\n`) - renderer.once("destroy", () => server.stop()) - } + const server = SimulationServer.start( + SimulationActions.createHarness(renderer), + DriveManifest.resolve().endpoints.ui, + ) + process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) + renderer.once("destroy", () => server.stop()) return renderer } -export * as Simulation from "./simulation" +export * as Drive from "./simulation" diff --git a/packages/simulation/src/manifest.ts b/packages/simulation/src/manifest.ts new file mode 100644 index 000000000000..0ef4530e4865 --- /dev/null +++ b/packages/simulation/src/manifest.ts @@ -0,0 +1,57 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +export interface Manifest { + readonly endpoints: { + readonly ui: string + readonly backend: string + } +} + +export const defaults: Manifest = { + endpoints: { + ui: "ws://127.0.0.1:40900", + backend: "ws://127.0.0.1:40950", + }, +} + +export function resolve() { + const name = process.env.OPENCODE_DRIVE + if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name") + if (name === "1") return defaults + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`) + + const directory = + process.env.DRIVE_REGISTRY_DIR ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances") + const file = join(directory, `${name}.json`) + if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`) + + const manifest: unknown = JSON.parse(readFileSync(file, "utf8")) + if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`) + validateEndpoint(manifest.endpoints.ui, "ui") + validateEndpoint(manifest.endpoints.backend, "backend") + return manifest +} + +function isManifest(value: unknown): value is Manifest { + if (typeof value !== "object" || value === null) return false + if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false + return ( + "ui" in value.endpoints && + typeof value.endpoints.ui === "string" && + "backend" in value.endpoints && + typeof value.endpoints.backend === "string" + ) +} + +function validateEndpoint(value: string, name: string) { + const endpoint = new URL(value) + const port = Number(endpoint.port) + if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) { + throw new Error(`Invalid drive ${name} endpoint: ${value}`) + } +} + +export * as DriveManifest from "./manifest" diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0d59eca42388..8d924c9e9b5c 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -205,9 +205,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, } satisfies CliRendererConfig - if (!!process.env.OPENCODE_SIMULATION) { - const { Simulation } = await import("@opencode-ai/simulation/frontend") - return Simulation.createSimulation(options) + if (process.env.OPENCODE_DRIVE) { + const { Drive } = await import("@opencode-ai/simulation/frontend") + return Drive.create(options) } return createCliRenderer(options)