Part IV · Five harnesses, one product modelOpenCode and normalization
Chapter 19source checked

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.

apps/server/src/provider/opencodeRuntime.ts:140–177 ↗verbatim · typescript · 846dada1
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;
Read this as: The ownership model distinguishes a scope-owned local child from an externally configured server. Concrete process acquisition and release are catalogued separately below.
Figure 19.1 · OpenCode has two ownership branches before one adapter boundaryonly the local server belongs to the session scope
OpenCode ownership and normalizationDiagram loading

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.

OpenCode ownership and normalization
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.

Figure 19.1. A configured server URL creates an external connection; T3 uses it but does not own its process. Without a URL, T3 starts a local OpenCode server inside the session scope and parses the URL from its stdout startup announcement. Both branches create an SDK client, select/adopt/fork an OpenCode session, and start a subscription pump. The adapter emits canonical hot runtime events. Closing the scope aborts the subscription and kills the local child, but not an external server.

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.

Interactive recovery decision

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

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
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

apps/server/src/provider/Layers/OpenCodeAdapter.ts:1430–1509 ↗verbatim · typescript · bd23ad46
    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],
        }),
Read this as: This excerpt shows promptAsync and active-turn steering. Interruption, history reads, and counted revert are catalogued as a separate range below.

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.

events emission branch found
Source-grounded normalization categories across five providers
OperationCodexClaudeCursorGrokOpenCode
transportnative app-server JSON-RPC mappingnative Agent SDK stream mappingnative ACP stdio mappingnative ACP stdio + XAI extension mappingnative SDK/HTTP event mapping
resumeadapter passes a Codex resume cursor to app-server runtimeadapter uses SDK resume metadataadapter passes native session id to ACP load/new flowadapter passes native session id to ACP load/new flowa recognized cursor re-adopts sessionId; confirmed miss starts fresh; absent/malformed/wrong-version cursor means no resume; cwd change forks
mid-turn sendcalls native turn/start; app-server may queue a new native turn id while interrupt still targets the current onequeues into the live SDK loop and reuses the active product turn idreuses active product turn while prompts are in flightreuses active turn with target-aware settlementcalls promptAsync and reuses active product turn
approval/inputnative JSON-RPC requests mapped to canonical request/input eventsSDK-side deferred interactions mapped to canonical request/input eventsACP permission + Cursor question extension mappingACP permission + XAI question extension mappingnative permission/question events mapped; replies call SDK endpoints
plans, tasks, subagentsnative plans plus multi-agent signals become plan and task. eventsTodoWrite, coordinator, and member observations become plan and task.Cursor plan/todo extensions become plan events; no task. emission branch foundACP plan observations become plan events; no task.plan mode selects a native agent and task-like tools become item activity; no plan or task.* emission branch found
commands and skills discoverysnapshot requests native skills and adds a feedback slash commandsnapshot combines initialization commands with discovered filesystem skillssnapshot exposes models/probe state; no skills or slash commands foundsnapshot exposes models/probe state; no skills or slash commands foundprovider inventory exposes skills; no slash commands found
live context telemetryemits canonical token-usage snapshotsemits canonical token-usage snapshots when normalization succeedsno canonical token-usage emission branch foundno canonical token-usage emission branch foundno canonical token-usage emission branch found
historical Usage sourceCodex JSONL session transcripts are scannedClaude JSONL project transcripts are scannednot scannednot scannednot scanned
rollbackThread behaviornative app-server rollback mappingadapter-local turn snapshot truncation plus resume-cursor refreshadapter-only local snapshot truncationexplicitly unsupportednative session.revert mapping
in-session model switchdeclared SPI capability: in-sessiondeclared SPI capability: in-sessiondeclared SPI capability: in-sessiondeclared SPI capability: in-sessiondeclared SPI capability: in-session
failure projectiontyped process/protocol/request failures plus runtime warning/error eventstyped SDK/request failures plus terminal result classificationtyped ACP failures plus provider-specific callback and cancel handlingtyped ACP failures plus late-event suppression around interruptiontyped 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.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.