OpenCode ownership, recovery, and five-provider normalization
OpenCode is an HTTP SDK integration with scoped local-server ownership or external-server attachment; its session adoption and cwd-fork policy show why a common product model must preserve provider-specific recovery semantics.
What this chapter resolves
- Trace OpenCode’s local/external server ownership and session-scoped event pump.
- Follow resume adoption, not-found handling, cwd equivalence, and history-preserving fork.
- Separate native OpenCode methods from canonical runtime events and durable orchestration state.
- Read the five-provider matrix as an evidence ledger, not a feature-negotiation protocol.
OpenCode enters T3 through the OpenCode SDK rather than ACP or Codex app-server JSON-RPC. That difference is architectural, not cosmetic: a session may own a locally spawned OpenCode server process, or it may attach to an externally managed server whose lifetime T3 must not end. In either case, the canonical provider adapter produces the same product-shaped hot runtime events—but it does not erase the ownership boundary.
Transport and server ownership
OpenCodeRuntime.connectToOpenCodeServer chooses between an explicit configured
server URL and a locally started server. The external branch returns a connection with
no scope-owned exit lifecycle. The local branch starts the OpenCode binary, waits for
its stdout startup announcement, parses the announced URL, and binds that child to the
Effect scope supplied by the caller. This startup path does not make a separate HTTP
readiness probe.
OpenCodeAdapter.startSession creates one session scope and uses it for the server,
the SDK event subscription, and exit-watch fibers.
export interface OpenCodeRuntimeShape {
/**
* Spawns a local OpenCode server process. Its lifetime is bound to the caller's
* `Scope.Scope` — the child is killed automatically when that scope closes.
* Consumers that want a long-lived server must create and hold a scope explicitly
* (see {@link Scope.make}) and close it when done.
*/
readonly startOpenCodeServerProcess: (input: {
readonly binaryPath: string;
readonly environment?: NodeJS.ProcessEnv;
readonly port?: number;
readonly hostname?: string;
readonly timeoutMs?: number;
}) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;
/**
* Returns a handle to either an externally-managed OpenCode server (when
* `serverUrl` is provided — no lifetime is attached to the caller's scope) or a
* freshly spawned local server whose lifetime is bound to the caller's scope.
*/
readonly connectToOpenCodeServer: (input: {
readonly binaryPath: string;
readonly serverUrl?: string | null;
readonly environment?: NodeJS.ProcessEnv;
readonly port?: number;
readonly hostname?: string;
readonly timeoutMs?: number;
}) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;
readonly runOpenCodeCommand: (input: {
readonly binaryPath: string;
readonly args: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
readonly cwd?: string;
}) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
readonly createOpenCodeSdkClient: (input: {
readonly baseUrl: string;
readonly directory: string;
readonly serverPassword?: string;
}) => OpencodeClient;Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
When an external OpenCode server URL is configured, connectToOpenCodeServer returns a handle marked external with no scope-owned process. Otherwise it starts a local server process whose lifetime belongs to the session scope, waits for the process to announce its URL on stdout, and does not issue a separate HTTP readiness probe in this startup path. In both cases the adapter creates an SDK client, obtains an OpenCode session, starts event.subscribe, and maps selected OpenCode events to ProviderRuntimeEvent. Stopping the adapter closes the session scope: it aborts event subscription and ends a local child process. It cannot terminate an externally managed server. Runtime events still require later ingestion before product projections become durable.
apps/server/src/provider/opencodeRuntime.ts:140–177 ↗apps/server/src/provider/opencodeRuntime.ts:477–639 ↗apps/server/src/provider/Layers/OpenCodeAdapter.ts:1139–1426 ↗apps/server/src/provider/Layers/OpenCodeAdapter.ts:900–1202 ↗apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:1490–1874 ↗The event pump uses an AbortController registered as a session-scope finalizer and
forks its subscriber and local-server exit watcher into that same scope. An unexpected
local exit is turned into an adapter-side runtime error. This is lifetime management,
not a persistent process supervisor: after T3 restarts, it must establish a new
connection and re-adopt a session if one is still available upstream.
Resume means re-adopt a native session id
T3 persists a versioned cursor containing an OpenCode sessionId. On a later
startSession, the adapter first decodes that envelope. An absent, malformed, or
wrong-version cursor means “no resume” and creates a fresh session without probing an
upstream id. For a recognized cursor, the adapter probes session.get; a confirmed
404 or exact NotFoundError can start fresh. Transport, authentication, and other
probe failures deliberately propagate: silently starting an empty conversation during
a transient failure would discard context.
The adapter compares requested and stored working directories carefully. Lexically
different names that resolve to the same canonical location reuse the native session.
If the adopted session belongs to a genuinely different cwd, T3 calls native
session.fork into the requested directory, reapplies the current permission rules,
and persists the new fork’s id. It chooses history continuity over a new empty
session. This is concrete OpenCode adapter behavior—not evidence that the other
harnesses offer the same native fork semantics.
What does an OpenCode resume cursor actually do?
Choose an observed condition; the outcome distinguishes provider-native recovery from durable product binding.
Selected No valid cursor: Create
No valid cursor
- Adapter decision
- An absent, malformed, or wrong-version cursor supplies no recognized native session id, so the adapter creates a new OpenCode session without a session.get probe.
- Durability boundary
- The resulting id is returned as a current-version resume cursor for the outer provider-binding persistence path.
All resume outcomes
- No valid cursor → Create
An absent, malformed, or wrong-version cursor supplies no recognized native session id, so the adapter creates a new OpenCode session without a session.get probe.
The resulting id is returned as a current-version resume cursor for the outer provider-binding persistence path.
- Confirmed missing → Create
session.get reports a structured 404 / NotFoundError, so the stored native session is gone and a fresh session is permitted.
This is a native-context loss that is explicitly recognized, not a successful replay of old hot events.
- Transient probe error → Fail
Authentication, transport, server, or non-404 failures propagate instead of silently creating an empty conversation.
No new cursor is minted; the existing durable binding remains the recovery clue for a later retry.
- Same directory → Adopt
The existing session is reused after lexical/real-path comparison and its permission rules are reasserted for the current runtime mode.
The existing session id remains the cursor; upstream history remains provider-owned.
- Different directory → Fork
The adapter asks OpenCode to fork the adopted session into the requested cwd, preserving upstream conversation history, then updates permissions.
The new fork id replaces the returned resume cursor; T3 is not copying native history into its own event log.
Turning native OpenCode events into product events
The adapter subscribes to OpenCode’s event stream and routes matching session events
into a local context. Examples include assistant content deltas, tool-part lifecycles,
permission questions and replies, user questions and answers, session busy/idle/retry,
and session errors. They become selected T3 runtime events such as content.delta,
item.updated, request.opened, user-input.requested, turn.completed, or
runtime.error.
It is tempting to say “OpenCode session status is the durable thread status.” It is not. The subscription and its maps of pending questions, permission requests, emitted text, part identities, and active turn are hot adapter state. A canonical runtime event crosses another boundary before internal orchestration commands make a durable event and update the product’s projections. A crash can therefore leave a native session doing work, a stale product projection, and a future recovery attempt that must re-adopt rather than replay the old hot stream.
Turn operations: send, steer, interrupt, ask, read, revert
sendTurn requires a provider/model slug, normalizes text plus attachments into
OpenCode prompt parts, and calls session.promptAsync. Optional model selection
options map agent and variant; a T3 plan interaction chooses the plan agent
unless an explicit agent option wins. A second send while activeTurnId exists is a
steer: it calls promptAsync again but reuses that existing product turn id.
Failure of a fresh prompt clears state and emits turn.aborted; failure of a steer
leaves the original active turn intact.
interruptTurn calls native session.abort and emits a canonical abort observation.
Permission and question responses are delegated to permission.reply and
question.reply only after the adapter confirms the request is still pending. That
check catches a stale UI action, but it is not a distributed exactly-once protocol.
readThread queries native session.messages and selects assistant-role entries for
its adapter snapshot. rollbackThread reads those messages, counts assistant entries,
then invokes native session.revert at the remaining assistant message—or without a
message id to revert the whole native thread when all assistant turns are removed.
That is real provider-side OpenCode behavior. It remains a separate saga from T3’s
durable thread-revert path and any workspace/Git checkpoint restoration.
const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
const context = yield* ensureSessionContext(sessions, input.threadId);
// A sendTurn while a turn is active is a steer: OpenCode queues the
// prompt into the busy session and the work continues as one turn, so
// the active turn id is reused instead of opening a new turn.
const steeringTurnId = context.activeTurnId;
const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`);
const modelSelection =
input.modelSelection ??
(context.session.model
? { instanceId: boundInstanceId, model: context.session.model }
: undefined);
if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) {
return yield* new ProviderAdapterValidationError({
provider: PROVIDER,
operation: "sendTurn",
issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`,
});
}
const parsedModel = parseOpenCodeModelSlug(modelSelection?.model);
if (!parsedModel) {
return yield* new ProviderAdapterValidationError({
provider: PROVIDER,
operation: "sendTurn",
issue: "OpenCode model selection must use the 'provider/model' format.",
});
}
const text = input.input?.trim();
const fileParts = toOpenCodeFileParts({
attachments: input.attachments,
resolveAttachmentPath: (attachment) =>
resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
attachment,
}),
});
if ((!text || text.length === 0) && fileParts.length === 0) {
return yield* new ProviderAdapterValidationError({
provider: PROVIDER,
operation: "sendTurn",
issue: "OpenCode turns require text input or at least one attachment.",
});
}
const agent = getModelSelectionStringOptionValue(modelSelection, "agent");
const variant = getModelSelectionStringOptionValue(modelSelection, "variant");
context.activeTurnId = turnId;
context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
context.activeVariant = variant;
yield* updateProviderSession(
context,
{
status: "running",
activeTurnId: turnId,
model: modelSelection?.model ?? context.session.model,
},
{ clearLastError: true },
);
if (steeringTurnId === undefined) {
yield* emit({
...(yield* buildEventBase({ threadId: input.threadId, turnId })),
type: "turn.started",
payload: {
model: modelSelection?.model ?? context.session.model,
...(variant ? { effort: variant } : {}),
},
});
}
yield* runOpenCodeSdk("session.promptAsync", () =>
context.client.session.promptAsync({
sessionID: context.openCodeSessionId,
model: parsedModel,
...(context.activeAgent ? { agent: context.activeAgent } : {}),
...(context.activeVariant ? { variant: context.activeVariant } : {}),
parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
}),Five providers, one comparison language
The book’s five harnesses are Codex, Claude, Cursor, Grok, and OpenCode. The matrix is
an evidence ledger with four cell types: native mapping, adapter behavior or
emulation, explicitly unsupported, and not evidenced in the inspected source.
It is not the ProviderAdapter SPI and not a runtime capability negotiation table.
The SPI’s declared capability object at this pinned revision contains only
sessionModelSwitch; these five adapters report it as in-session.
| Operation | Codex | Claude | Cursor | Grok | OpenCode |
|---|---|---|---|---|---|
| transport | native app-server JSON-RPC mapping | native Agent SDK stream mapping | native ACP stdio mapping | native ACP stdio + XAI extension mapping | native SDK/HTTP event mapping |
| resume | adapter passes a Codex resume cursor to app-server runtime | adapter uses SDK resume metadata | adapter passes native session id to ACP load/new flow | adapter passes native session id to ACP load/new flow | a recognized cursor re-adopts sessionId; confirmed miss starts fresh; absent/malformed/wrong-version cursor means no resume; cwd change forks |
| mid-turn send | calls native turn/start; app-server may queue a new native turn id while interrupt still targets the current one | queues into the live SDK loop and reuses the active product turn id | reuses active product turn while prompts are in flight | reuses active turn with target-aware settlement | calls promptAsync and reuses active product turn |
| approval/input | native JSON-RPC requests mapped to canonical request/input events | SDK-side deferred interactions mapped to canonical request/input events | ACP permission + Cursor question extension mapping | ACP permission + XAI question extension mapping | native permission/question events mapped; replies call SDK endpoints |
| plans, tasks, subagents | native plans plus multi-agent signals become plan and task. events | TodoWrite, coordinator, and member observations become plan and task. | eventsCursor plan/todo extensions become plan events; no task. emission branch found | ACP plan observations become plan events; no task. | emission branch foundplan mode selects a native agent and task-like tools become item activity; no plan or task.* emission branch found |
| commands and skills discovery | snapshot requests native skills and adds a feedback slash command | snapshot combines initialization commands with discovered filesystem skills | snapshot exposes models/probe state; no skills or slash commands found | snapshot exposes models/probe state; no skills or slash commands found | provider inventory exposes skills; no slash commands found |
| live context telemetry | emits canonical token-usage snapshots | emits canonical token-usage snapshots when normalization succeeds | no canonical token-usage emission branch found | no canonical token-usage emission branch found | no canonical token-usage emission branch found |
| historical Usage source | Codex JSONL session transcripts are scanned | Claude JSONL project transcripts are scanned | not scanned | not scanned | not scanned |
rollbackThread behavior | native app-server rollback mapping | adapter-local turn snapshot truncation plus resume-cursor refresh | adapter-only local snapshot truncation | explicitly unsupported | native session.revert mapping |
| in-session model switch | declared SPI capability: in-session | declared SPI capability: in-session | declared SPI capability: in-session | declared SPI capability: in-session | declared SPI capability: in-session |
| failure projection | typed process/protocol/request failures plus runtime warning/error events | typed SDK/request failures plus terminal result classification | typed ACP failures plus provider-specific callback and cancel handling | typed ACP failures plus late-event suppression around interruption | typed SDK/HTTP failures; when probing a recognized cursor, only confirmed not-found permits fresh-session replacement |
An absence cell means no implementation branch was found at this pinned revision; it does not claim the upstream product can never expose that feature. Chapters 16, 17, and 20 provide the longer Codex, Claude, and usage trails. The matrix keeps provider discovery (skills/commands), live adapter normalization (plans/tasks and context telemetry), and the independent transcript scanner in separate rows so one surface cannot masquerade as another.