Skip to content

Keep turn authority in mevedel across model engines

Status: accepted

Current decision

Claude subscription access runs the installed Claude agent through ACP, with mevedel's tools exposed through a private asynchronous MCP bridge. Mevedel owns request admission, execution authority, effects, canonical transcript and settlement; the external agent owns sampling, native model history and compaction. The gptel engine retains its real state machine, while external turns carry workflow context on the admitted request rather than manufacturing a gptel state machine.

Directive admission precedes yielding file preparation and save prompts. Its permission mode and Plan read-only stamp are explicit request inputs, so nested readiness work cannot consume another directive's authority. Provisional startup cleanup belongs to the reserved request and runs on cancellation before its ownership is cleared; stale startup continuations and rollback cannot mutate a replacement. This closes the observed save-prompt admission race without adding a second admission state machine or changing provider settlement.

The bridge resolves every call against current mevedel authority. Discovery is not permission, and a request's cancellation revokes pending interactions and effects. The native conversation identity is persisted before its prompt is sent, so process failure leaves an uncertain turn; the next prompt must acknowledge current-state reconciliation before further tools. Call identities are unique only within the live turn. Root and child histories remain separate and resumable; each directive request or shared-item question starts an isolated native conversation from its selected context and retains no identity, so neither enters root native history nor rotates root segments. Only a retained native identity restricts local-history operations; unstarted and released markers do not. The existing root interaction and agent registry decisions remain in ADR 0060 and ADR 0063.

Context reaches native history only at an observable SDK receipt: the exact block in the conversation's user-message echo, or a successful hook response carrying the exact hook output. Required context must be received before further tool effects and successful settlement. A delivery beyond Claude's inline hook limit continues the same admitted turn with a new prompt carrying the complete body; because SessionStart(compact) cannot stop the prompt, a PreToolUse hook denies and stops calls while that continuation is pending. The complete observation baseline stays in Claude's system prompt with the SDK's first-prompt snapshot disabled, so a resumed launch cannot keep stale mevedel instructions; restoration after native compaction supplies current differences. Prompts re-send path instructions and selected context only when the native conversation lacks their current form. The gptel payload rule remains in ADR 0115; behavior is described under native context delivery.

Acknowledged typed deliveries use the shared hidden injection record, including guidance restored after compaction; delivered mail follows as ordinary mailbox blocks. The receipt correlation marker stays on the wire. The shared record preserves source types and lets the normal renderer place initial deliveries with the user prompt and later deliveries at their point in assistant activity.

Root steering uses the same receipts. It rides the next tool-batch hook under a header that gives it user authority, or a further prompt when the native prompt ends first, and is consumed only on receipt. A native turn cannot park at a held boundary the way a gptel state machine waits, so steering still held when the turn succeeds becomes the first follow-ups.

External history changes what the transcript can faithfully reconstruct. Claude compaction publishes a successor transcript segment; selected context must be acknowledged again before subsequent tools. Cross-engine continuation uses the effective segment and labels a new native conversation as excerpt continuation. Same-machine resume retains native identity, including direct acquisition of a released or expired lease from an already open read-only view. Cooperative transfer from a live owner still refuses retained native histories. Operations requiring an exact native checkpoint mapping, including Fork and Rewind, refuse before effects when that mapping is unavailable. These constraints are documented in session lifecycle, rather than hidden behind a transcript copy that claims exact model-history equivalence.

Raw transcript edits mark the owning root or child native history divergent; continuation requires explicit excerpt recovery. Recovery reads the effective summary from its canonical root or child bounds and includes it once as labelled compaction evidence. Ordinary transcript segmentation omits leading root summaries for rendering, and edits can inherit the closing wrapper's ignored property; neither determines which summary the recovered model receives.

Connection ownership includes the protocol library's deferred drains and watchdogs. A connection-local filter holds timers the library schedules while a TRAMP operation is on the stack and arms them when it returns; outside one the filter runs unchanged. Watchdogs validate their captured identity before closing the connection, so a retired timer restored from a suspended list cannot close a healthy conversation. MCP calls and hook decisions arrive synchronously on their socket while ACP frames wait for the library's drain timer. Before queuing socket work, the runner reads ACP output that is already available, then queues behind the drain that output armed. Every ACP frame Emacs can read when a socket message is handled is therefore processed first, whichever descriptor Emacs happened to read first. The receipt and the call reach Emacs through different relays (the adapter's stdout and the MCP bridge), so a receipt still inside the adapter is not ordered by that read alone. Claude's adapter announces every tool use with an ACP tool_call whose toolCallId is the MCP call's claudecode/toolUseId, after the text streamed before it, so a Claude MCP call waits until its announcement has been queued, for at most two seconds, reading and continuing the paced adapter meanwhile. Text and receipts the adapter wrote before the call are then processed first. Queued work waits for idle target transport, and reentrant arrivals cannot overtake an in-progress segment publication. An interrupted request still consumes events the agent already reported, such as completed compaction, and its owned terminal acknowledgement for usage and settlement, including a reply whose publication is waiting for target transport. It rejects new tools and hooks: queued ones receive failure replies without running, so the peer can reach its terminal reply, and cannot acquire a replacement turn's authority. A hook whose client gave up is not run later. A reply whose client closed before Emacs read that it had is not a turn failure: the MCP server retires that client, cancels its other pending work once and reports the lost tool result or hook reply, without its payload, to the owner. The turn keeps its local result and effects, never replays the call, records the loss as a bounded diagnostic and lets the agent's own terminal outcome settle it. A lost hook reply is not a receipt, so its staged context stays undelivered. Only a failed write that leaves the client closed is treated so. Any other write failure still signals to the owner, also from a completion made before dispatch returns, and the client is answered with that error; an unencodable result is answered as an error. Retained native invocations and isolated workloads use the same handling and bounded acknowledgement; a retained invocation's runtime canceller defers terminal settlement until the connection completes it.

Provider identity lives in mevedel-claude-code-backend.el: the backend type, model metadata, registration, and generic methods are available without ACP, MCP, or Claude usage execution. The methods call autoloaded runtime entry points. Setup and actual Claude requests load those implementations; native gptel discovery, chat creation, and readiness do not need them.

Headless recovery and runtime maintenance

Provider readiness belongs to the harness, before root input is committed. Claude readiness is learned from root turn startup rather than a separate probe: every send's own launch is the check, and the next send is the retry. A failed startup leaves its cause as an informational issue, because a blocking one would also wedge Goal, plan and directive sends; an explicit browser retry checks again. Configuration, login and runtime changes forget the result. Saved provider selections may fall back to one configured provider or the host default, with a visible notice; explicit invalid selections remain errors. A live provider failure moves the session only on a structured model-not-found signal. Missing presets require owner selection, without changing permission or sandbox authority.

Only owners that re-check an issue report it as blocking: Codex login, preset restore and saved-model restore, each clearing only its own issue. Request, agent and other failures are informational and cleared by the next root request, which is the user's retry. Informational notices can also be dismissed explicitly, without erasing results or transcripts. A successful retained-agent retry clears only its own previous notice; other root recovery remains untouched. Authentication recovery uses asynchronous Codex refresh/device login and the Claude CLI's subscription login/status interfaces. Credentials remain local; owner peers receive ephemeral URL/code challenges. Unsent input survives a clean exit and may resume after repair, while submitted failure or interruption requires explicit continuation. Native history recovery still requires an idle conversation and preserves the transcript and effects.

Bounded sample recovery stays below the admitted turn boundary for all mevedel-admitted native gptel backends, not just Codex OAuth. The mevedel-gptel-retry controller retries a prepared model sample up to 10 times after its initial attempt. Success resets that sample allowance; root, directive, shared-item and retained-agent requests own independent budgets. Replay bypasses WAIT preparation, retaining tools/results, sample count, request identity, model, effort and permissions. Attempt-local provisional output is rolled back and obsolete callbacks are inert. Backoff is cancellable without a live transport; cancellation, interruption, deadlines, buffer death and teardown cannot leave a delayed producer active.

gptel retains native transport and provider parsing; generic recovery uses conservative HTTP-status and structured-error classification, with permanent structured errors taking precedence over transient status or message text. Small Responses-specific normalization fills missing error/completion events, including errors in HTTP-200 streams, without imposing that protocol on other providers. Parser adapters preserve native optional arguments. Standard SSE/JSON envelopes enable completion checks; unknown native extension formats retain gptel's own end-of-stream policy. Transient server, overload, temporary rate-limit and recognized transport errors, including narrowly matched model-at-capacity messages without retry advice, can recover. Authentication/permission, invalid request, model-not-found, context overflow, quota exhaustion and local bookkeeping errors cannot; output-limit and content-filter completion are not interrupted transport, and unknown errors are not presumed transient. Intermediate failures publish status, not terminal error blocks. Exhaustion settles once through the existing pipeline, persists bounded chronological attempt diagnostics with the original provider error, and pauses an affected Goal rather than invoking its settled-turn retry policy. An owned permanent refusal also pauses the affected Goal instead of gaining new-turn retries. Reported usage has a distinct physical-call identity and is charged once; absent usage stays unknown. Zero mevedel-gptel-retry-max-retries disables the controller. Unadmitted background gptel policies are unchanged. Claude Code/ACP stays separate and self-retrying, retaining its built-in retry behavior and the credential-lock replay below. The all-backend admission contract does not assert paid-service verification for every provider. Details are in bounded sample recovery.

One prompt failure is absorbed instead of returned. Every Claude process on the host shares one credential store. A prompt that needs a token refresh while another process holds the refresh lock, or left it stale by exiting, fails with the CLI's "another Claude Code process is refreshing it" refusal before any model request. A planning agent was lost this way with zero tool calls, and the lock had cleared a minute later, so failing the turn only handed the caller a wait. The Claude launch recognizes that refusal and the ACP prompt re-sends the same prompt once, 30 seconds later, in the same conversation. It does so only while the prompt has reported no tool call, so the replay repeats no effect. The streamed refusal stays in the transcript, and the wait keeps the conversation busy. Cancelling during the wait settles that turn at once, without cancelling a successor started by its completion callback. After the replay is sent, cancellation again waits for the prompt's acknowledgement. Any other failure, a second refusal, or a refusal after a tool call still ends the turn. Errors in the retry policy also settle the turn: acp.el contains callback exceptions but cannot release mevedel's active-turn state.

Queued input records one durable delivery intent, a sidecar-only dispatching mark, before dispatch; delivery writes nothing further, so a committed prompt cannot return to the runnable queue. Other queue and pause changes use the coalesced sidecar save, so a crash can lose queue changes from its debounce interval, and a session without a committed sidecar keeps its queue only in memory; the intent write then has nothing to update. On restart, interrupted delivery and uncertain native turns require explicit review before continuation. A failed turn pauses delivery only for input it left undelivered or uncertain. Refused guest input is dropped with a notice rather than held, unless the refusal left a blocking issue whose owner re-checks it, such as an expired Codex login: then it waits in the queue and its sender learns the issue's public reason. Dropping it there told a browser owner only that the host "could not run" the message, though the owner could sign in from the browser and the queue would resume itself. OAuth readiness also runs at each gptel sampling boundary, so expiry during a tool loop settles the turn without synchronous login.

CLI/adapter maintenance runs asynchronously behind an installation lock, which holds even when create-lockfiles is nil. Launches start a due check; an explicit check reports progress and its result in the echo area. The native CLI updates on its configured autoUpdatesChannel (default latest): claude install saves the channel it installs, so a fixed channel would silently move users. Native CLI releases are pinned before installation can prune them; adapters are staged by version. Version checks and an initialization-only ACP handshake gate activation. Current, previous, rejected and active runtimes survive cleanup. A rejected pair waits for a new version or an explicit check. External installations are not replaced. Authentication failure is separate from runtime compatibility. A successful runtime update invalidates cached readiness and clears dependency failures once per session; it does not mark provider authentication as repaired. Credential and installation locks remain mandatory when the host disables editor file locks, and contention never opens a lock-breaking prompt.

Rationale and consequences

This boundary preserves the existing session workflow and the tested tool pipeline while using the supported subscription login. Treating Claude as an HTTP backend would misrepresent its retained history and native tool loop. Text-emulated tools would replace that loop and add another model-facing protocol. Direct stream-json would couple session communication to one CLI; ACP provides reusable session transport, with Claude-specific prompt, tool isolation, authentication and receipt extensions kept in its engine modules. Supporting ACP does not promise that arbitrary agents provide those controls.

ACP send completion does not prove that hook context entered the native conversation, and a hook preview or offloaded file is not the complete input. The installed adapter exposes the SDK's user-message echoes and hook lifecycle messages, which make delivery observable without a second history store. Receipt establishes SDK acceptance, not model understanding or retention through later compaction.

Decision history

  • Native retry scope: the first bounded controller admitted only Codex OAuth requests, interpreting Responses-specific error handling as a provider boundary. The user clarified that the intended policy was generic: every mevedel-admitted native gptel backend should recover the prepared sample, while Claude Code/ACP and unadmitted background policies remain separate. Cross-backend review also confirmed a global Gemini streaming regression: installed parser advice accepted three arguments, but Gemini passes the optional fourth include-text argument even outside Codex recovery. This demonstrated that a Codex-only admission gate did not isolate global adapters. Recovery now follows request admission rather than Codex backend type; adapters preserve native arguments and leave provider parsing to gptel, with only a small missing-event normalization layer specific to Responses.

  • Recovery after client loss: the cooperative-transfer guard also rejected direct lease acquisition for native sessions. After an Emacs crash, an already open read-only view could not recover even though ordinary resume preserved the same native identity. Direct acquisition now follows resume's history policy; the cooperative protocol retains its native-history guard.

  • Notice lifetime: waiting for the next root request left a child failure banner visible throughout a long-running turn, even after work continued. Informational notices now allow explicit dismissal and clear the child's old notice after its retry dispatch succeeds. Blocking issues remain owned by their recovery checks, and failure evidence remains in retained results.

  • Steering: busy Claude input first became queued follow-ups, because the native loop offered no same-turn user input mevedel could observe. The receipt-backed tool-batch hook already delivered mail and turn events inside the running turn, so steering now rides it and keeps HTTP's "next boundary, never abort" contract. The adapter's own _session/steering request was rejected: it pre-empts the current generation, moves a running tool call to the background, and splits one turn into several results.

  • Tool hook decisions: review found that native turns called post-tool hooks but ignored their decisions, allowing a stopped turn to execute another tool and sending results a hook intended to withhold. The bridge now applies stop, block and replacement results before delivery and publication, and passes pre-hook argument replacements through the existing pipeline checks. Real MCP subprocess regressions verify both the outgoing payload and the absence of a subsequent tool call after a stop.

  • Transport ownership: the initial callback queue protected work after ACP notifications were decoded. Deterministic subprocess tests then showed that an ordinary TRAMP wait could discard ACP's earlier drain timer, leaving its queue permanently busy, and restore a cancelled startup watchdog that closed an admitted conversation. Ownership now begins at the connection's process filter and follows its scheduled callbacks. The first retention let-bound timer-list around the whole filter; cancel-timer then reached only the temporary list, so cancelled ACP timers fired anyway. Retention now applies only while a TRAMP operation is nested, moving filter timers to a held list.
  • Cancellation: cancellation first discarded every notification after abort, losing final usage the agent had acknowledged and a native compaction it had completed. Reported events and terminal accounting now survive cancellation, without reopening tool or continuation authority.
  • Socket ordering: a tool call read in the same pass as an earlier context receipt ran first and failed the turn with an unacknowledged-context error. Queuing socket work behind the drain timer fixed it only when Emacs read the ACP pipe before the socket; a review repro with the reads swapped (Emacs reads ready descriptors in no fixed order) failed the turn the same way. The runner now first reads ACP output already available, so the receipt's drain is armed before the socket work's timer.
  • Text overtaken by a tool call: a transcript showed a sentence cut in two around a tool row: the sentence's remaining chunk was written after the row. The adapter had been paused for text pacing; the CLI's MCP call reached the bridge while that chunk was still in the adapter, and the 50-ms wake-up read only its first output. A live probe confirmed the adapter announces each MCP call on stdout, with the same tool-use ID, normally 20-360 ms before the call. The call now waits for that announcement. A fixture writing the call before its preceding text and announcement reproduces the split without the wait and keeps the text ahead of the row with it.
  • Lost MCP client: a native turn ended with "Process mevedel-mcp no longer connected to pipe; closed it". A reply was written to a client whose peer had closed while Emacs had not yet read its end; Emacs then signals that error and closes the client, and every owner turned it into a turn failure, hiding the agent's real outcome and failing isolated workloads the same way. Why the peer closed was not established. Real-socket regressions reproduce the write, and fixture turns lose only their bridge or a hook at its timeout. Batch Emacs keeps the SIGPIPE disposition it inherits, under which that write kills the process, so those cases run in a child that ignores SIGPIPE as an interactive Emacs does. Review then found an older gap beside it: a completion made before dispatch returned retired its request, so when its write failed on a live client the dispatch failure handler completed it again and the error vanished, leaving the client unanswered. Only a failure while the answer is still owed now becomes the answer; a failure after completion began keeps signalling.
  • Native call ledger: call identities were first committed to the session sidecar before each tool executed and kept for the conversation's lifetime to reject replays. Profiling put that commit at 51 ms of synchronous main-thread time per compiled tool call, and the ledger grew without bound and was copied every turn. The protection was redundant: the in-flight record is already durable before the prompt, a crash restores it as uncertain, and uncertain history requires an acknowledged reconciliation notice before more tools. The adapter was never observed replaying an old toolUseId, and a model retry uses a new one. Per-turn uniqueness remains; the ledger was removed.
  • Directive identities: directive turns first persisted their identity under the directive scope, and released or failed startups left unstarted markers. None were ever resumed, but any entry permanently refused Fork, Rewind, Redo, Save As, control transfer, manual compaction and side conversations after one Claude directive or child, including the two-machine transfer workflow. Directive identities are no longer stored, and the guard counts only retained native identities.
  • Oversized delivery: mail first reached a running conversation only through hooks, and updates above the hook limit failed explicitly, needing another user turn. A child result above the limit could never reach a running native root: the hook skipped it and WaitAgent returned immediately on the non-empty mailbox, looping. Prompt submission now carries mail in full, and an oversized hook update, including one message, continues the admitted turn through another prompt.
  • Compaction stop: the continuation path first assumed that SessionStart(compact) honors a stop. A live compaction run showed the CLI continuing, with the MCP admission guard failing closed, and showed that PreToolUse also needs an explicit deny beside its stop.
  • Repeated prompt context: every prompt first re-sent acknowledged path instructions and user-placement selected context, accumulating copies in Claude's retained history. Content hashes now suppress unchanged deliveries.
  • Transcript form of receipts: receipts first stored the wire batch, marker included. That lost source labels, placed initial guidance under the assistant, rendered markers as user prose or reasoning, leaked them into excerpts and cost the view a regexp pass over every hidden segment. The transcript now holds only the typed record and mail.
  • Native summary segments: restoration hooks alone left the transcript with all pre-compaction history, which continuing through gptel would revive as raw turns. Native compaction events now publish Claude's retained summary as a segment.
  • Cross-engine continuation: an initial blanket guard refused switching engines once history existed; the user rejected it. Effective native compaction segments and gptel's reasoning-safe projection provide a usable continuation boundary in both directions.
  • Setup and maintenance: the initial integration required terminal login and an explicitly installed, pinned adapter. Headless browser use exposed inaccessible recovery prompts and stale installations. Owner-only typed recovery and automatic checked maintenance replaced that setup policy while retaining the installed CLI's credential and conversation ownership.
  • Recovery scope: the first recovery layer blocked a session on any classified request failure, checked Claude readiness with a full native session whose result expired after 30 seconds, paused delivery after every failed or aborted turn, held refused guest input with a blocking issue, and saved the full session on every queue change. Regexes over provider text, such as "upgrade" in a billing message, persisted blocking issues no Emacs action cleared. Most Claude sends were refused once and started Claude twice. A single abort stopped follow-up delivery indefinitely. A refused /compact from a guest locked out the host. Queue saves cost about 70 ms each on a 400 KB transcript. Blocking state now belongs to owners that re-check it, readiness to turn startup, the pause to affected input, and queue durability to the sidecar.
  • Provider discovery: recognizing the Claude backend first loaded ACP, MCP and usage execution, even for native gptel sessions. The backend module now owns the small provider contract separately from setup and execution.