How OpenCode uses one server process for all repositories instead of spawning a separate server per project.
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.
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.
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().
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()
},
})
})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 requestedworktree— 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.
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
}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.
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.
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.
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.