opencode Under the Hood: Durable State, Live Streams, and Reversible Sessions

Cover Image for opencode Under the Hood: Durable State, Live Streams, and Reversible Sessions

Listen with Article TTS Reader

Checking for Article TTS Reader…

Codex treats execution containment as a first-class runtime problem. This third Agent Harness post follows the Codex analysis with opencode, where the central question is different: how can an agent session be durable, observable while streaming, and reversible after it changes a workspace?

I reviewed the opencode repository at revision b72b50006b24. Every file reference and conclusion below is limited to that revision; it does not describe later behavior.

The loop reloads durable history each time around

packages/opencode/src/session/prompt.ts contains runLoop, with an unmistakable while (true) at its center. Before each iteration, it rebuilds the prompt history through MessageV2.filterCompactedEffect(sessionID). The loop therefore does not treat one in-memory message array as the session's sole source of truth.

The durability boundary is worth being precise about. Complete message and part updates become durable events with relational projections in SQLite. Streaming PartDelta events are live observations rather than persistent records. On a normal text-end, the accumulated text is written into the full text part; cleanup also checkpoints accumulated text during controlled interruption or handled failure. A process crash can still lose characters that arrived after the latest checkpoint.

This design gives the user interface prompt token-by-token feedback without pretending that every transient delta has survived a crash. It also means recovery has a clear boundary: the most recent durable checkpoint.

The loop has a second important defensive rule. It checks for actual tool parts rather than trusting a provider's finish reason alone. The comment at prompt.ts:1103-1105 describes providers that return stop even while an assistant message contains tool calls. If those calls exist, the loop continues so their results reach the model.

ensureRunning in effect/runner.ts gives a session one active prompt loop. A second prompt joins and waits for the existing run rather than creating a competing loop. Operations that explicitly require an idle session, such as shell startup or revert, can still report Busy.

The default tool dispatcher is delegated, with local guardrails

The reviewed default route adapts internal tools through SessionTools.resolve() and passes them to Vercel AI SDK v6's streamText({ tools, ... }). The SDK dispatches calls and returns normalized events. An opt-in native runtime exists behind OPENCODE_EXPERIMENTAL_NATIVE_LLM, with fallback behavior where it does not apply, so SDK dispatch is the default path in this revision rather than the only path.

opencode still owns the tool boundary. Tool.define() wraps every tool with three controls:

  • Schema failures are rewritten into messages the model can act on.
  • Outputs are truncated before they overwhelm context.
  • OpenTelemetry spans preserve execution visibility.

processor.ts adds runtime defenses around the event stream. It detects a doom loop when the three most recent tool calls share the same name and parameters, then requires a permission decision to break the repetition. The AI SDK integration enables repair for case-mismatched tool names. Interrupted calls are marked with status: "error" and metadata.interrupted: true, allowing the main loop to recognize an orphaned call rather than accidentally treating it as work that should continue.

The broader pattern is practical: outsource provider-specific tool dispatch where it helps, then preserve local semantics for validation, context limits, telemetry, interruption, and human intervention.

A message is metadata plus a stream of parts

The session model separates SessionV1.Info from Part[], where parts represent text, reasoning, tools, patches, compaction, and related content. Message IDs have ordering semantics, though pagination sorts by time_created and then uses ID to break ties. That is a useful correction to the tempting simplification that ordering ignores timestamps.

SQLite and Drizzle persist the message and part projections. The event model supports a simple mental model: durable complete parts provide recovery, while non-durable deltas serve real-time subscribers. In the reviewed implementation, the TUI and desktop client are subscribers to these state events rather than owners of a separate session model.

Context compaction has three recovery layers

Context pressure is handled at three places:

  1. A stream can be cut off when it reaches a compaction condition.
  2. A caught ContextOverflowError starts compaction and retries the turn.
  3. Compaction produces an anchored summary plus a recent conversation tail, and filterCompacted returns a reordered view: summary, retained tail, then later messages.

That last point is easy to miss. message-v2.ts explicitly notes that the array position is not chronological after compaction. The system is constructing a prompt view, rather than claiming to return an untouched chronological event log.

An optional prune() marks older tool parts as compacted for rendering and replaces them with placeholder text. The source data remains in the persisted parts and events. This is logical context suppression, not physical deletion or disk reclamation.

Fork, revert, and share are session operations

Fork copies messages before a chosen message ID into a new session and remaps their IDs. The more distinctive operation is revert. Before and after each model output, the runtime snapshots a shadow Git repository. File changes are also stored as structured patch parts in the message stream. Revert applies those patches to restore the workspace, providing a form of session-level time travel.

Sharing is implemented separately from local storage. The sync protocol uploads messages, parts, and session_diff; the shared code diff uses the latter rather than relying on the abbreviated patch-part representation.

The event transport follows the same projection model. HTTP Server-Sent Events broadcast general state changes. WebSockets are used primarily for bidirectional PTY and proxy paths. The UI is literally consuming the agent's state stream.

Approval is a policy decision, not a sandbox

Permission matching in permission/index.ts uses wildcard rules, where the last matching rule wins and the default action is ask. A request awaiting approval blocks on an Effect Deferred until a client responds. Choosing "always" updates an instance-level cache and releases comparable pending requests.

The Bash tool extracts paths and patterns from Bash or PowerShell syntax trees using tree-sitter WASM, then matches those against permission patterns. Access outside the workspace uses a separate external_directory permission.

This is a capable approval system, and its boundary needs to stay clear. In the reviewed main path, Bash invokes ChildProcess.make directly; there is no operating-system sandbox around that execution path. The permission system determines whether the harness asks a person. It does not independently confine a command beyond the permissions of the running process.

What this revision shows

opencode's core contribution is a durable, observable state flow. Complete message and part updates create recoverable checkpoints. Transient deltas keep interfaces live. Compaction constructs a smaller working view. Forking, workspace snapshots, and sharing extend the same session model into useful operational controls.

The tradeoffs are visible too. Effect-TS raises the conceptual bar for readers unfamiliar with its runtime model. Delegating the default tool-dispatch path to an SDK makes provider integration broader while placing some dispatch semantics outside the repository. The absence of an operating-system sandbox in the reviewed main Bash path leaves approval as the principal safety mechanism.

These are bounded observations from revision b72b50006b24. Together with Claude Code and Codex, they show three different ways to turn the same small model-and-tools loop into a system that can survive real work.

Source index

  • Main loop and finish-reason defense: packages/opencode/src/session/prompt.ts:1081-1105
  • Tool wrapper: packages/opencode/src/tool/tool.ts:99-149
  • Event processor and doom-loop threshold: packages/opencode/src/session/processor.ts:29,278-676
  • Compaction view: packages/core/src/session/message-v2.ts:521-581
  • Shadow Git snapshots: packages/opencode/src/snapshot/index.ts:75-148
  • Permission default and blocking approval: packages/opencode/src/permission/index.ts:28-107