opencode/specs/v2/session.md

34 KiB

Session API

Current V2 Core Slice

The Effect-native core facade treats prompt recording and execution as separate responsibilities:

sessions.create({ id?, location, ... })
  -> omitted ID generates one internal Session ID
  -> supplied ID creates the Session when absent
  -> reused ID returns the existing Session identity

sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
  -> omitted ID generates one internal message ID
  -> supplied ID inserts one durable Session inbox row when absent
  -> exact reuse returns the same admission receipt
  -> reusing one message ID for another Session, prompt, or delivery mode fails
  -> exact retry schedules another wake unless resume is false
  -> resume omitted or true schedules execution after admission
  -> resume false admits only

sessions.interrupt(sessionID)
  -> interrupts active execution on this process
  -> waits for runner cleanup and a terminal lifecycle observation
  -> clears a coalesced follow-up wake already registered with this coordinator
  -> preserves durable inbox rows for a later wake or resume
  -> idle or missing Session is a no-op

sessions.active()
  -> snapshots foreground Session drains owned by this process
  -> returns only active Session IDs with { type: "running" }
  -> absence means inactive; activity is not durable across process restarts

session_input is the typed durable admission inbox for prompts and Session control operations. PromptAdmitted records accepted user input; Compaction.Admitted records one coalesced manual compaction barrier. Admitted prompts remain outside model-visible Session history until the serialized runner publishes Prompted. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. A pending compaction blocks all unpromoted prompts, runs before the Session would otherwise become idle, and releases the backlog only after its durable ended or failed event settles the barrier. The V1-to-V2 shadow bridge publishes the same Prompted event for already-visible V1 prompts.

admittedSeq is the durable Session event sequence of PromptAdmitted. Clients may use the admission event to represent queued input before Prompted makes it part of visible conversation history.

Execution routing starts from only the Session ID:

SessionExecution.resume(sessionID)
-> SessionStore.get(sessionID)
-> LocationServiceMap.get(session.location)
-> SessionRunner.drain({ sessionID, force? })

SessionExecution and the read-side SessionStore are process-global. SessionRunner, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted Location.workspaceID means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.

The local runner issues one explicit llm.stream(request) per step, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every owned tool fiber after provider-stream closure, and reloads projected history once before continuation. For every in-process step, session.step.started precedes its tool calls, every local and hosted call settles as session.tool.success or session.tool.failed, and only then may the runner publish the single terminal session.step.ended or session.step.failed. Streamed provider-error evidence is retained until this closeout; thrown provider failures and interruption use the same settlement-first ordering. Promoting any new user input resets the selected agent's configured step allowance; multiple steers promoted at one boundary reset it once.

callID is unique only within its owning step, not across the Session. Tool events therefore carry assistantMessageID, and consumers correlate a call through the step that owns that assistant message rather than inventing a synthetic composite key. Before assembling a provider request, the runner's cross-drain failInterruptedTools recovery durably fails any tool still projected as pending or running from a previous process with Tool execution interrupted. This orphan-recovery sweep is the explicit nesting exception: it occurs in a later drain, but attributes every settlement to the original assistantMessageID; abandoned side effects are never silently replayed.

session.execution.started.1 and exactly one of session.execution.succeeded.1, session.execution.failed.1, or session.execution.interrupted.1 observe one process-local coordinator busy period, including coalesced drains and joined resumes. These durable rows are history, not a durable execution identity: replay must never infer current liveness, recovery, grouping, or resumability from an unmatched start. A drain has no durable identity or transcript boundary. /api/session/active is the authority for current process-local liveness, and is empty after restart. User interruption records reason: "user"; owner-scope interruption defaults to "shutdown"; "superseded" is reserved for explicit replacement.

Core retries only typed rate-limit, provider-internal, and transport failures before durable assistant text, reasoning, tool-call, tool-output, or tool-execution evidence. The initial call plus at most four retries use two-second exponential backoff, raised when a provider's retryAfterMs is larger. Every retry attempt remains a distinct step and consumes the selected agent's step allowance, while all pre-output attempts reuse one assistant message ID so retry state never creates empty transcript messages. Repeated session.step.started.1 facts reopen that assistant projection idempotently. session.retry.scheduled.1 is committed before each delay with the upcoming one-based attempt and absolute epoch-millisecond time, then projects onto Assistant.retry. The next session.step.started.1 or terminal failure/interruption clears it. A scheduled retry surviving a crash is historical UI state only and never triggers recovery.

A normalized step-finish with content-filter publishes session.step.failed.1 with provider.content-filter, never session.step.ended.1. Any partial streamed content remains visible; a contentless filtered response still has a failed assistant projection.

Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.

Instruction Checkpoints

V2 Sessions persist the exact privileged instructions shown to the model. InstructionCheckpoint stores one immutable instruction baseline, its baseline event sequence, and a model-hidden Instructions.Applied record used to compare independently observed instruction sources. Instructions are only one part of Model Context: the runner separately assembles agent or provider system text, Session History, tool definitions, and step-local additions for each request.

The runner has no instruction registry. loadInstructions explicitly loads these producers concurrently and combines them in this fixed order:

  1. Instruction built-ins, currently environment facts and the host-local date.
  2. InstructionDiscovery, observing ambient AGENTS.md files.
  3. Selected-agent available-skill guidance.
  4. Reference guidance.
  5. Selected-agent MCP guidance.
  6. API-managed InstructionEntry values for the Session.

Instructions.combine(...) preserves that caller order and rejects duplicate namespaced source keys. Each source owns its typed observation, JSON codec, and pure baseline, update, and optional removal renderers.

The first complete observation initializes InstructionCheckpoint before any pending prompt becomes model-visible. If an initial source is temporarily unavailable, execution stops while the prompt remains pending and retryable. Every later step attempt also prepares instructions before input promotion. Changed instructions publish one durable chronological System message through session.instructions.updated, and that event commit advances Instructions.Applied atomically.

Client            Runner                 Explicit producers       InstructionCheckpoint      Inbox / History       LLM
   │                 │                            │                          │                       │               │
   ├─ Admit prompt ────────────────────────────────────────────────────────────────────────────────▶               │
   │                 │                            │                          │                       │               │
   │                 ├─ Load instructions ───────▶                          │                       │               │
   │                 │                            │                          │                       │               │
   │                 ◀─ Combined sources ─────────┤                          │                       │               │
   │                 │                            │                          │                       │               │
   │                 ├─ Initialize or reconcile ────────────────────────────▶                       │               │
   │                 │                            │                          │                       │               │
   │                 ├─ Publish update + advance Applied atomically ───────────────────────────────▶               │
   │                 │                            │                          │                       │               │
   │                 ├─ Promote eligible input ────────────────────────────────────────────────────▶               │
   │                 │                            │                          │                       │               │
   │                 ├─ System text + instruction baseline + history + tools ──────────────────────────────────────▶

Agent and model selection are step-scoped. The runner selects the agent before loading agent-specific guidance; a switch admitted after the current boundary applies to the next step without restarting the current one. Changed guidance is admitted through session.instructions.updated while preserving the baseline. Model selection affects Model Context assembly but is not an instruction source and does not itself replace the instruction baseline.

A completed compaction causes the next physical attempt to rebaseline from current instructions. Temporarily unavailable sources are restated from the model's last applied belief where possible. A Session move resets InstructionCheckpoint so the destination Location initializes a complete baseline on its next run. Committed revert also resets the checkpoint.

Session                      InstructionCheckpoint
   │                                   │
   ├─ initialize complete baseline ────▶
   │                                   │
   │                                   ├──────────────────────────────╮
   │                                   │ reconcile instruction update │
   │                                   ◀──────────────────────────────╯
   │                                   │
   ├─ completed compaction ────────────▶ rebaseline
   │                                   │
   ├─ move or committed revert ────────▶ reset

InstructionDiscovery observes ambient instructions as one ordered aggregate source. Ambient discovery canonicalizes traversal within the project root, reads global and upward-project AGENTS.md files, and honors OPENCODE_DISABLE_PROJECT_CONFIG for project files.

An unavailable observation preserves the previously applied value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message.

Current instruction follow-ups:

  • Add configured and remote instruction sources with explicit precedence and removal semantics.
  • Add durable post-crash continuation recovery for promoted or provider-dispatched work.
  • Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
  • Consider watcher-backed per-file caching only if measurements show direct step-boundary observation is too expensive.
  • Design any plugin-defined instruction contribution as an explicit runner composition boundary; do not reintroduce a registry implicitly.
  • Add clustered Session execution ownership and stale-runtime fencing.

Automatic Compaction

Before each step, the runner estimates the complete model-visible request and compares it with the selected model's context window minus absolute reserved headroom. The reserve is the greater of the requested/model output allowance and configured compaction.buffer. When the request exceeds that budget and older complete steps are available, the runner compacts before executing the pending step.

Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.

The rolling summary is a continuation checkpoint with this complete heading order: Objective, Important Details, Work State, and Next Move. Work State records completed, active, and blocked work, while Next Move records the immediate and following actions. Every heading remains present even when its value is (none).

session.compaction.admitted.1 durably records a manual request and projects its queued transcript row. session.compaction.started.1 identifies the attempt and transforms that row into a running divider. Compaction deltas are live-only progress rendered beneath it. session.compaction.ended.1 durably stores the final summary and serialized recent context, completes the same row, and settles the manual barrier. session.compaction.failed.1 settles an unsuccessful manual barrier without changing the previous history boundary. On the next physical attempt, the runner observes a completed compaction and directly renders a fresh instruction baseline through InstructionCheckpoint.

Assistant text and reasoning follow a strict started / live-only delta / durable full-value ended lifecycle. A publisher permits at most one open fragment of each kind in a step and fails on a second start before the matching end. Provider block IDs remain internal to LLM adapters; each fragment event carries a Session-assigned kind-specific ordinal, matching the ordinal derived from projected content. UI identity is therefore the assistant message ID plus content kind and ordinal. Tool calls retain step-scoped callID because settlements and provider replay correlate through it.

Provider continuation state is opaque and un-nested at the Session boundary. The publisher selects only the active model provider's entry from LLM provider metadata. Same-model replay re-nests that state under the current provider; model switches and failed assistant steps continue to suppress provider-native continuation state.

Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step.

When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up.

V1 Model Context Parity

This is the canonical checklist for Model Context still needed before the V2 runner replaces V1. Keep each behavior in its owning boundary rather than treating all model-visible text as durable Instructions. Update this table in the PR that changes a status.

Status: complete is usable in the native V2 path, partial covers only part of V1 behavior, and missing has no native V2 equivalent.

Boundary Behavior Status Remaining V2 work
Durable Instruction Source Environment facts and host-local date partial Keep selected provider/model identity in step request assembly rather than a stale Location-wide instruction value.
Durable Instruction Source Global and upward project instructions partial Decide whether V2 also discovers legacy CLAUDE.md and deprecated CONTEXT.md.
Durable Instruction Source Configured local/glob and remote URL instructions missing Add independent sources with explicit precedence, unavailable, and removal semantics.
Durable Instruction Source Nearby nested instructions discovered after successful reads missing Persist discoveries and admit them at the next safe step boundary.
Durable Instruction Source Selected-agent available skill guidance and skill-body loading partial Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization.
Step request assembly Placement, selected model, chronological history, and canonical lowering complete None.
Step request assembly Selected agent, agent prompt, and effective permissions partial V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy.
Step request assembly Provider/model-specific base instructions complete Native V2 selects the provider-family baseline unless the effective agent overrides it.
Step request assembly Policy-filtered built-in, MCP, plugin, and structured-output tools partial Materialize definitions for the effective agent and request.
Step request assembly Per-prompt system text and tool overrides missing Design admission and durable replay semantics before exposing them.
Step request assembly Steering, plan/build-switch, and final-step reminders missing Add only reminders whose behavior remains part of V2.
Step request assembly Plugin message, system, parameter, and header transforms missing Design V2 plugin hooks and lifecycle semantics.
Step request assembly Model variants and request settings partial Apply effective agent options and future plugin-mutated request settings.
Step request assembly Structured-output policy missing Add prompt format, generated tool, tool choice, and model-visible policy together.
Step request assembly Automatic/context-pressure compaction complete V2 initiates automatic and overflow-triggered compaction, then rebuilds the baseline from the completed checkpoint.
Prompt/reference expansion Durable typed prompt attachments complete None.
Prompt/reference expansion Native template and @ mention expansion missing Parse and resolve native V2 prompt input before durable admission.
Prompt/reference expansion File, directory, media, and MCP-resource materialization partial Materialize and normalize sources instead of lowering unresolved attachment metadata.
Prompt/reference expansion Agent-reference expansion missing Produce permission-aware model-visible task guidance.
Prompt/reference expansion Configured-reference expansion missing Resolve aliases and emit durable model-visible reference context or failures.
Prompt/reference expansion Native synthetic expansion replay partial V2 replays synthetic messages but only the V1 compatibility path creates them.

Provider timeout and watchdog policy is intentionally deferred. Retry tuning beyond the narrow safe policy above remains separate work; the runner does not impose a universal provider-stream inactivity or absolute timeout.

Inbox delivery is explicit:

  • steer inputs promote at the next safe step boundary, including continuation inside the current drain.
  • queue inputs remain in a FIFO while the current drain requires continuation. When the Session would otherwise become idle, the runner promotes exactly one queued input, then reevaluates continuation before promoting another.

Execution has two entry points:

  • run is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a physical attempt.
  • wake reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input.

Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit run may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need.

A process-global SessionRunCoordinator serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new step against that Location. Its durable lifecycle events are historical observations only; they do not replace the coordinator's process-local active registry.

The coordinator's active registry is also the source for sessions.active(). It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart.

Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.

Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per step. Before broadening exposure, revisit per-step call limits, output truncation, and operational backpressure using observed workloads. The normalized Session event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.

The normalized Session event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and sessions.messages(...) pagination follow durable event order even when caller-supplied IDs differ; event time is carried by the envelope created field rather than duplicated in payloads. Consumers can use sessions.log({ sessionID, after? }) to replay durable Session events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream.

The first sessions.log(...) contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries.

sessions.log(...) captures the aggregate's durable watermark before replay and emits one log.synced marker when replay reaches that watermark. The marker carries seq when the captured watermark is non-empty and omits it when the log has no sequence. With follow=true, the tail subscribes before the replay watermark is captured, so events committed during replay are delivered after log.synced with a sequence greater than the marker watermark. Durable reads are paged internally; consumers observe only the ordered event stream plus the single marker.

sessions.history({ sessionID, after?, limit? }) is the finite counterpart for request/response consumers. after is an exclusive aggregate sequence, and omission starts before sequence zero. The response is { data, hasMore }; callers derive the next after from the final event's durable sequence when hasMore is true. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. The log has a moving head, so events committed between pages may appear on the next page.

The finite endpoint is GET /api/session/:sessionID/history, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing sessions.log() replay-and-tail stream is unchanged except for its explicit log.synced replay marker.

sessions.history({ sessionID, after?, limit? }) is the finite counterpart for request/response consumers. after is an exclusive aggregate sequence, and omission starts before sequence zero. The response is { data, hasMore }; callers derive the next after from the final event's durable sequence when hasMore is true. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. The log has a moving head, so events committed between pages may appear on the next page.

The finite endpoint is GET /api/session/:sessionID/history, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing sessions.events() replay-and-tail stream is unchanged.

Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state.

Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration.

Current Tool Registry Slice

Each Location-scoped ToolRegistry stores scoped tool registrations, materializes definitions, and owns lookup and settlement. Built-ins and plugins contribute through the same Tools.Service.register(...) path. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors.

When a Session omits agent, both execution and permission evaluation use the default build agent. A caller must not observe build model behavior while permission checks silently evaluate an empty no-agent policy.

The first built-in contribution is bounded read:

resolve one path relative to the Location or a named project reference
-> reject absolute paths, path escapes, and symlink escapes
-> authorize read against the canonical resource identity
-> for a file: return UTF-8 text or base64 binary content; page oversized UTF-8 text by bounded line ranges
-> for a directory: return direct children in directory-first alphabetical order
-> page directory results with one-based offset and next cursor

V2 bash uses the normal permission semantics: configured agent rules plus saved project approvals, with ask as the default when no rule matches. Bash is not sandboxed: the spawned shell runs with the host user's filesystem, process, and network authority. Structured external workdir resolution remains an enforced external_directory authority check. Best-effort scans of absolute command arguments produce advisory warnings only; they are not sandbox boundaries and do not request or enforce external_directory approval.

The first V2 apply_patch leaf supports add, update, and delete hunks. It parses every hunk, resolves every mutation target, approves external directories, approves one edit batch, and preflights approved update/delete targets before committing operations sequentially. A later commit-time failure leaves earlier operations applied and returns an explicit partial-application report. Moves and atomic rollback remain separate follow-ups rather than implied behavior.

Current Runner Follow-Ups

  • Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after step consumption, persist every result, and reload history once before continuation.
  • Buffer or coalesce streamed deltas before rewriting growing assistant projections.
  • Revisit additional covering indexes as larger-history query shapes become concrete.
  • Design any global multi-Session event stream separately; the finite history API deliberately reads one authorized Session aggregate and does not change global Event publication.
  • Decide whether UI-facing Session subscriptions should optionally interleave ephemeral deltas while connected without advancing the durable cursor.
  • Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly.

Remove Dedicated session.init Route

The dedicated POST /session/:sessionID/init endpoint exists only as a compatibility wrapper around the normal /init command flow.

Current behavior:

  • the route calls SessionPrompt.command(...)
  • it sends Command.Default.INIT
  • it does not provide distinct session-core behavior beyond running the existing init command in an existing session

V2 plan:

  • remove the dedicated session.init endpoint
  • rely on the normal /init command flow instead
  • avoid reintroducing Session.initialize-style special cases in the session service layer