Files
openwork/prds/server-v2-plan/spawning-opencode.md
Source Open 12900a0b9e feat(server-v2): add standalone runtime and SDK foundation (#1468)
* feat(server-v2): add standalone runtime and SDK foundation

* docs(server-v2): drop planning task checklists

* build(server-v2): generate OpenAPI and SDK during dev

* build(server-v2): generate API artifacts before builds

* build(server-v2): drop duplicate root SDK generation

* build(app): remove SDK generation hooks

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
2026-04-17 09:54:26 -07:00

12 KiB

Spawning OpenCode

Status: Draft

Date: 2026-04-13

Purpose

This document describes the local helper we need for starting OpenCode when the new server ships with a bundled OpenCode binary.

The key problem is:

  • @opencode-ai/sdk provides a createOpencode() helper
  • that helper expects to launch opencode from PATH
  • our distribution plan wants the OpenWork server to ship its own bundled OpenCode binary
  • therefore we need a local helper that mimics the useful startup behavior while allowing an explicit binary path

Design Goal

Build a small local helper that:

  • starts OpenCode directly with Bun runtime process APIs
  • accepts an explicit binary path
  • waits until OpenCode is actually ready
  • accepts a custom config object generated by the OpenWork server
  • returns an OpenCode HTTP client from @opencode-ai/sdk
  • does not modify the upstream SDK

The SDK should still be used for the HTTP client.

The SDK should not be used for server startup.

Why We Need This

In the target Server V2 distribution model:

  • openwork-server-v2 is the primary executable
  • it embeds opencode
  • it extracts opencode into a managed runtime directory
  • it launches that exact binary path

So the normal case is not:

  • look on PATH for opencode

The normal case is:

  • use the exact extracted OpenCode binary path managed by the server runtime

That means relying on PATH is the wrong startup model.

We need deterministic control over:

  • which opencode binary is launched
  • which hostname and port it binds to
  • which config blob it receives
  • how readiness and failure are detected

That config blob should come from the OpenWork server's own database-backed config model, not from ad hoc client state.

Proposed Helper Location

Suggested local helper location:

apps/server-v2/src/adapters/opencode/local.ts

This should remain app-local/server-local code, not a patch to node_modules.

Proposed Public API

Use one exported function:

createLocalOpencode(opts?)

Suggested signature:

type CreateLocalOpencodeOptions = {
  binary?: string
  hostname?: string
  port?: number
  timeout?: number
  signal?: AbortSignal
  config?: Record<string, unknown>
  client?: Record<string, unknown>
}

Suggested defaults:

  • binary: omitted only if the helper can resolve the extracted bundled runtime binary path from server-managed runtime state; otherwise startup should fail
  • hostname: "127.0.0.1"
  • port: 4096
  • timeout: 5000

Important rule:

  • the helper should not rely on PATH
  • the helper should always launch the bundled OpenCode binary extracted by the server runtime
  • if no explicit bundled binary path is available, that should be treated as a startup error, not as a signal to fall back to a system binary

Runtime Behavior

The helper should:

  1. resolve the binary path
  2. spawn OpenCode with serve
  3. watch stdout/stderr for readiness
  4. parse the listening URL
  5. create an OpenCode HTTP client using createOpencodeClient
  6. return both the client and server process handle

Spawn Contract

Spawn should use:

binary
serve
--hostname=<hostname>
--port=<port>

If config.logLevel exists, append:

--log-level=<config.logLevel>

Environment should merge process.env with:

OPENCODE_CONFIG_CONTENT=<json string>

Where:

JSON.stringify(config ?? {})

Config Source Of Truth

The config object passed into this helper should be generated by the OpenWork server.

That means:

  • the server reads its sqlite state
  • the server resolves the effective config for the relevant runtime or workspace
  • the server materializes an OpenCode config object
  • the helper passes that object through OPENCODE_CONFIG_CONTENT

The helper should not decide config policy.

Its job is to:

  • receive the already-computed config object
  • serialize it
  • launch OpenCode with it

This keeps the ownership boundary clean:

  • server DB and services own config state
  • the spawn helper owns process startup only

SDK Usage

Use the SDK only for the HTTP client:

import { createOpencodeClient } from "@opencode-ai/sdk"

The helper should create the client after readiness:

createOpencodeClient({
  baseUrl: url,
  ...clientOpts,
})

Readiness Detection

The helper should detect readiness by watching stdout and stderr for a line like:

opencode server listening on http://...

It should:

  • capture stdout and stderr incrementally
  • parse the URL from the first matching line
  • treat that as the canonical base URL

Suggested parser behavior:

  • scan each emitted line
  • match http://... or https://...
  • store the parsed URL exactly as reported

Failure Handling

The helper should fail clearly in three main cases.

1. Missing binary / ENOENT

If the binary does not exist or cannot be launched:

  • reject immediately
  • include the binary path/name in the error
  • include a clear explanation that the executable was not found

Suggested error shape:

Failed to start OpenCode: executable not found at <binary>

2. Timeout before readiness

If startup exceeds the timeout:

  • kill the child process
  • reject with a timeout error
  • include collected stdout/stderr in the error message

Suggested error shape:

OpenCode did not become ready within 5000ms.
Collected output:
...

3. Early exit before ready

If the child exits before a readiness line is seen:

  • reject with an early-exit error
  • include exit code or signal
  • include collected stdout/stderr

Suggested error shape:

OpenCode exited before becoming ready (exit code 1).
Collected output:
...

Runtime Exit And Crash Handling

Startup success is not enough.

The server also needs to handle the case where OpenCode exits or crashes after readiness.

The helper and the surrounding runtime supervisor should support:

  • detecting unexpected child exit after readiness
  • surfacing a clear runtime state change to the rest of the server
  • exposing crash/error state to clients
  • controlled restart behavior

Required runtime behavior

Once OpenCode is ready, the server should continue monitoring the process.

If the child exits unexpectedly:

  • mark OpenCode as unhealthy or offline in runtime state
  • capture the exit code or signal
  • capture recent stdout/stderr for diagnostics
  • make that status visible through server APIs
  • either restart automatically or leave the process down based on the server's restart policy

Restart policy

The exact restart policy is still a design decision, but the system should be built so it can support:

  • no automatic restart
  • bounded restart attempts with backoff
  • explicit manual restart via server control surface

The important point is that OpenCode process failure must be a first-class runtime state, not an invisible child-process problem.

Abort Support

If signal is provided:

  • attach an abort listener
  • kill the spawned child on abort if startup is still in progress
  • reject with an abort-related error

The same signal can also be used later by the caller to coordinate shutdown policy if desired.

Returned Shape

Suggested return shape:

type LocalOpencodeHandle = {
  client: ReturnType<typeof createOpencodeClient>
  server: {
    url: string
    close: () => void
    proc: Bun.Subprocess
  }
}

Notes:

  • client is the upstream SDK HTTP client
  • server.url is the parsed listening URL
  • server.close() should terminate the spawned process
  • server.proc is the Bun subprocess handle for diagnostics and advanced lifecycle management

The process handle should also make it possible for the server's runtime supervisor to observe post-start exit/crash behavior.

Suggested Implementation Notes

Use:

import { createOpencodeClient } from "@opencode-ai/sdk"

Preferred runtime API inside apps/server-v2:

Bun.spawn(...)

Reasoning:

  • the new server is Bun-based
  • Bun is the runtime we are standardizing on for apps/server-v2
  • using Bun-native process APIs keeps the implementation aligned with the rest of the server runtime

Compatibility note:

  • node:child_process.spawn would also work under Bun for many cases
  • but it should not be the preferred framing for this helper in the design docs
  • the helper should be documented as a Bun-based local runtime helper

Implementation guidance:

  • use TypeScript
  • avoid any
  • collect output safely with bounded buffers if needed
  • make timeout cleanup and process cleanup deterministic
  • remove abort hooks, timers, and background readers after resolve/reject
  • only resolve once
  • continue observing the process after readiness so unexpected exit/crash can be surfaced to the runtime supervisor

Suggested Example Usage

const opencode = await createLocalOpencode({
  binary: "/absolute/path/to/opencode",
  config: {
    model: "anthropic/claude-3-5-sonnet-20241022",
  },
})

console.log(opencode.server.url)

opencode.server.close()

Tiny Usage Example

import { createLocalOpencode } from "./adapters/opencode/local"

const opencode = await createLocalOpencode({
  binary: "/runtime/opencode",
  hostname: "127.0.0.1",
  port: 4096,
})

const projects = await opencode.client.project.list()

console.log(projects)

opencode.server.close()

Expected Implementation Skeleton

Illustrative shape only:

import { createOpencodeClient } from "@opencode-ai/sdk"

export async function createLocalOpencode(opts: CreateLocalOpencodeOptions = {}) {
  // resolve defaults, including extracted bundled binary path when present
  // spawn process with Bun.spawn
  // capture stdout/stderr
  // wait for readiness line
  // enforce timeout
  // reject on early exit
  // create client with parsed URL
  // return { client, server }
}

Relationship To The Distribution Plan

This helper is important because it connects the runtime distribution plan to the actual server implementation.

In the target distribution model:

  • openwork-server-v2 embeds opencode
  • extracts it to a managed runtime directory
  • then uses this helper to launch the extracted binary by absolute path

That makes startup deterministic and independent from whatever happens to be on PATH.

OpenCode Health Endpoint

The server should expose an endpoint that allows clients to check OpenCode health.

At minimum, clients should be able to ask:

  • is OpenCode running?
  • what version is running?
  • what URL is it bound to?
  • when did it last start or fail?

Suggested shape:

GET /system/opencode/health

Example response shape:

{
  "running": true,
  "version": "1.2.27",
  "baseUrl": "http://127.0.0.1:4096",
  "lastStartedAt": "2026-04-13T12:00:00Z",
  "lastExit": null
}

If OpenCode crashed, the server should be able to report something like:

{
  "running": false,
  "version": "1.2.27",
  "baseUrl": "http://127.0.0.1:4096",
  "lastStartedAt": "2026-04-13T12:00:00Z",
  "lastExit": {
    "code": 1,
    "signal": null,
    "at": "2026-04-13T12:05:00Z"
  }
}

This endpoint is important because the UI should not infer OpenCode health from indirect failures alone.

The server should make runtime state explicit.

Recommendation

Implement this helper locally in the new server codebase and treat it as the canonical OpenCode startup wrapper.

It should be designed around the Bun-based runtime model of apps/server-v2, not around a generic Node-only process model.

It should also be designed as one piece of a larger runtime supervision system, not just a fire-and-forget spawn helper.

It should become the place where we later add:

  • version validation
  • health probes beyond stdout readiness
  • structured logs
  • cleanup/restart semantics
  • possibly router-adjacent startup coordination if needed