Skip to content

Instantly share code, notes, and snippets.

@remorses
Created March 7, 2026 16:13
Show Gist options
  • Select an option

  • Save remorses/b0389ce431bd96cd6e68124891d2df7f to your computer and use it in GitHub Desktop.

Select an option

Save remorses/b0389ce431bd96cd6e68124891d2df7f to your computer and use it in GitHub Desktop.
How OpenCode uses one server process for all repositories via x-opencode-directory header

Single Server, Multiple Directories

How OpenCode uses one server process for all repositories instead of spawning a separate server per project.

The problem

If you spawn a new opencode serve per repository, you end up managing N server processes, N ports, N health checks — and the client needs to track which server maps to which project. This doesn't scale.

How OpenCode solves it

OpenCode runs one opencode serve process. Every API request includes an x-opencode-directory header that tells the server which project directory to operate in. The server lazily creates and caches an Instance per unique directory path.

                  ┌──────────────────────────────────────────────┐
                  │           opencode serve (:4096)             │
                  │                                              │
 SDK Client A ────┤  x-opencode-directory: /home/user/project-a  │──▶ Instance A (cached)
                  │                                              │
 SDK Client B ────┤  x-opencode-directory: /home/user/project-b  │──▶ Instance B (cached)
                  │                                              │
 SDK Client C ────┤  x-opencode-directory: /home/user/project-c  │──▶ Instance C (cached)
                  │                                              │
                  └──────────────────────────────────────────────┘

All three clients hit the same host:port. The directory header is the only thing that differs.

Setting the directory on the SDK client

The JS SDK accepts a directory option when creating a client. It converts it into the x-opencode-directory header automatically:

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

// client A — all requests scoped to project-a
const clientA = createOpencodeClient({
  baseUrl: "http://localhost:4096",
  directory: "/home/user/project-a",
})

// client B — all requests scoped to project-b
const clientB = createOpencodeClient({
  baseUrl: "http://localhost:4096",
  directory: "/home/user/project-b",
})

Under the hood (packages/sdk/js/src/v2/client.ts:21-28):

if (config?.directory) {
  const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
  const encodedDirectory = isNonASCII
    ? encodeURIComponent(config.directory)
    : config.directory
  config.headers = {
    ...config.headers,
    "x-opencode-directory": encodedDirectory,
  }
}

You can also pass it as a query parameter ?directory=/path instead of a header. The server checks both:

const raw = c.req.query("directory")
  || c.req.header("x-opencode-directory")
  || process.cwd()

If neither is provided, the server defaults to process.cwd().

How the server handles it

Every request (except /log) goes through a middleware that reads the directory and wraps the request in an Instance.provide() call (packages/opencode/src/server/server.ts:195-212):

.use(async (c, next) => {
  if (c.req.path === "/log") return next()
  const raw = c.req.query("directory")
    || c.req.header("x-opencode-directory")
    || process.cwd()
  const directory = decodeURIComponent(raw)
  return Instance.provide({
    directory,
    init: InstanceBootstrap,
    async fn() {
      return next()
    },
  })
})

Instance caching

The Instance module (packages/opencode/src/project/instance.ts) keeps a Map<string, Promise<Context>> keyed by directory path. First request for a directory creates the instance; subsequent requests reuse it:

const cache = new Map<string, Promise<Context>>()

async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }) {
  let existing = cache.get(input.directory)
  if (!existing) {
    existing = (async () => {
      const { project, sandbox } = await Project.fromDirectory(input.directory)
      return {
        directory: input.directory,
        worktree: sandbox,  // git root, or "/" for non-git dirs
        project,
      }
    })()
    cache.set(input.directory, existing)
  }
  const ctx = await existing
  return context.provide(ctx, () => input.fn())
}

Each Instance carries:

  • directory — the exact path the client requested
  • worktree — the git repository root (or "/" for non-git directories)
  • project — project metadata (config, settings, etc.)

Route handlers access these via Instance.directory, Instance.worktree, Instance.project — they never need to know which directory was requested, it's implicit from the middleware context.

Switching projects in the UI

When a user clicks a different project in the sidebar, no server restart or reconnection happens. The flow is:

1. User clicks project

// sidebar-project.tsx:120
onClick={() => props.navigateToProject(props.project.worktree)}

2. URL changes to encode the directory

// layout.tsx:1096-1101
function navigateToProject(directory: string) {
  server.projects.touch(directory)
  const lastSession = store.lastSession[directory]
  navigateWithSidebarReset(
    `/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`
  )
}

3. A new SDK client is created with the new directory

// sdk.tsx:13-22
const client = createMemo(() =>
  globalSDK.createClient({
    directory: directory(),  // from URL params
    throwOnError: true,
  }),
)

4. All subsequent SDK calls use the new header

The client is a memo — when directory() changes, a new SDK client is created with the updated x-opencode-directory header. Every call (session.list(), session.create(), path.get(), etc.) now targets the new project.

The app also caches SDK clients per directory to avoid recreating them on every switch (packages/app/src/context/global-sync.tsx:151-160):

const sdkFor = (directory: string) => {
  const cached = sdkCache.get(directory)
  if (cached) return cached
  const sdk = globalSDK.createClient({ directory, throwOnError: true })
  sdkCache.set(directory, sdk)
  return sdk
}

SDK calls that use the directory context

These calls all inherit the directory from the x-opencode-directory header — you don't pass the directory again in the request body:

SDK Call Purpose
client.session.list() List sessions for that directory
client.session.create() Create a new session in that directory
client.session.get({ id }) Get a session (scoped to directory)
client.session.messages({ id }) Get messages for a session
client.path.get() Get directory/worktree paths
client.project.list() Get project config

The session.list endpoint also accepts an explicit directory query param for filtering, but this is a filter within the instance — not the same as the header that selects which instance to use.

Disposing instances

When a project is closed, the app can dispose its server-side instance to free resources:

// instance.ts:69-82
async dispose() {
  await State.dispose(Instance.directory)
  cache.delete(Instance.directory)
  GlobalBus.emit("event", {
    directory: Instance.directory,
    payload: {
      type: "server.instance.disposed",
      properties: { directory: Instance.directory },
    },
  })
}

The server also supports disposeAll() for shutdown, which iterates the cache and disposes each instance.

When multiple servers are actually needed

The single-server model covers the common case. Multiple servers are used only for:

Scenario Connection Type How it works
Remote machine http User adds a remote opencode serve URL in settings. The app connects to it over HTTP/HTTPS
SSH tunnel ssh Desktop app opens an SSH connection and proxies HTTP through it
WSL on Windows sidecar (variant wsl) A second sidecar process runs inside WSL alongside the native one

Each server connection is keyed independently (packages/app/src/context/server.tsx:74-85):

const key = (conn: Any): Key => {
  switch (conn.type) {
    case "http":    return Key.make(conn.http.url)
    case "sidecar": return conn.variant === "wsl"
                      ? Key.make(`wsl:${conn.distro}`)
                      : Key.make("sidecar")
    case "ssh":     return Key.make(`ssh:${conn.host}`)
  }
}

Projects are tracked per server key. Local connections (sidecar + localhost) share the "local" key, so their project lists merge. Remote servers each get their own project list.

Migrating from multi-server to single-server

If you currently spawn one opencode serve per repository:

1. Start a single server — run opencode serve --port 4096 once, without any project-specific flags.

2. Replace per-project server URLs with a single base URL plus the directory option:

// before: different server per repo
const client = createOpencodeClient({ baseUrl: "http://localhost:4001" }) // project-a server
const client = createOpencodeClient({ baseUrl: "http://localhost:4002" }) // project-b server

// after: one server, directory header
const client = createOpencodeClient({
  baseUrl: "http://localhost:4096",
  directory: "/home/user/project-a",
})

3. Switch projects by creating a new client with a different directory — or by passing ?directory= as a query param on individual requests if you want per-request flexibility.

4. Clean up — remove any process management, port allocation, and health check logic for per-project servers. The single server handles instance lifecycle automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment