Provider seam v2: generalize the agent-CLI contract so lab's chat works with any agent CLI¶
ADR-0016 built the embedded chat behind the AgentProvider seam and promised
that "a future Codex/Gemini chat is one new implementation and zero refactor."
That promise was aspirational: the universal schema carried only single-question
dialogs, ADR-0016 stated flatly "there is no slash-command UI", clearing
context was an accident of Claude's transcript-file rotation, auth and seeding
were claude-shaped, and internal/httpapi imported claudecode for its typed
errors. This issue makes the promise real. It generalizes the seam so lab's chat
works with any agent CLI (Codex CLI, Gemini CLI, …) and proves it
provider-neutral by construction — without implementing a second provider
here (#2 tracks Codex). Claude Code is brought up to the generalized contract;
the seam is now the written surface a second provider ports against, not a
claude shape a second provider must reverse-engineer.
The proof is structural, not a promise. Every claude-specific assumption is
lifted into one of two places: provider-declared metadata (DisplayName,
AuthFlow, SeedMeta, the Commands catalog — the "provider declares, lab
renders generically" pattern ADR-0021 established for spawn options) or a
documented seam obligation (the clear/epoch duty on LocateTranscript, the
reply-while-working and interrupt footnotes). Nothing outside
internal/provider/claudecode hardcodes a model, an effort, a provider name, a
login mechanic, a seeded path, or a clear command — enforced by the SPA
grep-guard and by httpapi importing no concrete provider. The generic layer
stays pure data-in/data-out; the adapter owns every fragile CLI coupling, each
pinned in its own internal/compat section.
This ADR amends ADR-0016 and ADR-0020.
ADR-0016 (embedded chat):
- The universal schema gains dialog kinds and multi-question forms.
Dialog.Kind(question | plan | approval) andDialog.Questions []Questionjoin the schema. A single-question dialog keeps the original flat shape (Prompt/Options/Multi,Questionsnil) byte-for-byte — the compat snapshots stay valid, existing single-picker behavior is unchanged. A multi-question form (len(Questions) >= 2) carries each question with its own options and multi-select flag; the UI collects all answers in one submit andPOST /runs/{id}/answergrows a positional per-questionanswers[](answers[i]answersQuestions[i]). - Sent slash commands are conversational context, not a breadcrumb to drop.
ADR-0016 (hardened by issue #45) dropped a local-command echo entirely. It now
renders as a plain user text message showing the command line (
/clear,/foo bar baz), and a non-empty<local-command-stdout>renders as a follow-up lifecycle message. This reverses only the rendering half of #45; the state half stands unchanged — command echoes never touch state derivation, which is exactly what keeps a post-/clearfresh transcript from readingworkingforever. - Clearing context is a documented seam duty, not luck. ADR-0016 depended,
unstated, on Claude rotating its transcript file on
/clear. That becomes the clear/epoch obligation onLocateTranscript/ReadTranscript: an adapter MUST surface a new conversation identity whenever its provider clears context, and lab detects the clear only by its effect — the located transcript changing — never by parsing any provider's clear command. Claude satisfies it for free by rotating the file (compat §5); an adapter whose CLI clears in place must synthesize a new epoch (e.g. an epoch-qualified path) so the identity still rotates andseqrestarts. - ADR-0016's "there is no slash-command UI — slash commands are plain text through the composer" is superseded: there is now a curated command catalog behind composer autocomplete (decision 5 below), while execution still rides the plain-text reply path.
ADR-0020 (answerable dialogs via the hook spool):
DialogHookerbecomes the provider-neutralLiveSignals— the same contract (spawn-time setup returning a settings payload + argv injection, pending-dialog read, blocked-state read, cheap change signature, spool sweep), the one method renameHookSettings→Setup. The capability was never claude-specific; only its PreToolUse/PostToolUse/Notification hook implementation is, and that stays exactly as ADR-0020 shipped it.- The two shapes ADR-0016/0020 left deep-link-degraded are now answerable.
Multi-question
AskUserQuestion(ADR-0020: "render the real content + a deep-link hint") and plan approvalExitPlanMode(ADR-0020: "approve/reject stays TUI-owned per the never-scrape rule") are both answerable in-chat now, through synthesized option rows and the sequenced picker recipe — and, unlike ADR-0016's original single-picker recipe, this time driven end-to-end against the live binary before shipping (see Live verification).
The decisions, pinned as shipped (settled with the operator 2026-07-08 — do not re-litigate):
- Read-through stands (reaffirms ADR-0016 decision 1). Every provider runs its real TUI under tmux; the adapter maps the provider-native transcript into the universal schema on read. There is no lab-owned message store — a reply lands in native history through the provider's own recording, and an ended run stays readable as long as the provider retains the file. The generalization changes what the seam declares, never this premise.
- Clear rides the command catalog and is observed by effect. There is no
seam-level clear method. Discovery: a
CommandSpecmay carryRole(CommandRoleCleartoday; claude's/clear, a codex/new), which the frontend binds to a "New conversation" affordance. Effect: the clear/epoch obligation above. Visibility: the echo-as-user-text rendering above. Together these mean lab never learns any provider's clear command — it offers a catalog entry, sends it as pasted text, and adopts the rotated transcript when the located file changes. The post-/clearstuck-composer regression is guarded: a transcript whose only tail is a command echo derivesidle, notworking, so the composer offers Send and not a stuck pulsing Interrupt (regression-tested; compat §5). - Dialog kinds, multi-question forms, and adapter-owned bidirectional
translation. The adapter owns both directions. Outbound: for pickers
whose rows are not in the structured tool input — the free-text "Other" row,
the whole
ExitPlanModepicker — the adapter synthesizes the option rows as write-side pinned constants, compat-verified per provider version exactly like the keystroke recipes; the never-scrape rule is untouched, nothing reads the pane. Inbound: the adapter alone decides what an answer becomes (keystroke sequences for a TUI; RPC for a future provider). The generic layer stays pure data; the existing guards (per-session lock,tool_idmatch, re-read under lock) stay generic. A post-resolve verification backstop is mandatory for both shapes:AnswerDialogrecords the intended answer in-memory (pertool_use_id, bounded at 100, oldest-evicted), and when the retro-flushedtool_resultlands,ReadTranscriptcompares the recorded outcome against the intent and emits a lifecycle warning on mismatch, so a blind-sequence desync is visible in the chat. The backstop is advisory-only — intents are in-memory, so a warning vanishes after a lab restart (accepted; persisting it would buy a rare warning's permanence with a whole storage surface). - Conversational state is unchanged and best-effort (reaffirms ADR-0016
decision 11). The five-state vocabulary (
idle | working | needs_input | question | ended) is the contract. Every adapter's baseline duty is to distinguishworking/idle/endedfrom its transcript;needs_inputandquestionarrive only where a live signal channel exists. No per-provider state metadata. - A mandatory
Commandscatalog drives composer autocomplete.Commands(ctx, worktree) ([]CommandSpec, error)is a new mandatory seam method — worktree-dependent (project/user commands are discovered relative to the run's worktree), so it is served per run asGET /runs/{id}/commandsand cached ~30s. claude-code merges a pinned builtin table (compat §10 — extracted from the 2.1.198 bundle, descriptions verbatim, primary names only) with worktree scans of.claude/commands/, the seeded user-invocable skills under.claude/skills/, and the user-level command dir. The adapter curates the list to chat-safe entries:ChatSafe=falsemarks a command that would strand the TUI in a picker/editor/UI lab cannot see (never-scrape: lab could neither render nor answer it) or that fights lab's own management of the session; the provider returns every row honestly, the API layer filters. The frontend autocompletes when the composer input starts with/. LiveSignalsgeneralizes the live channel, with honest degradation. A provider without a verified structured signal channel simply omits the capability (a type assertion at the call site, the ADR-0017 pattern) — never a scrape, no half-measure. Auto-approval spawn defaults keep dialogs rare, and a residual blocked state degrades toneeds_inputplus the copyable tmux attach affordance (ADR-0017). lab keeps transcript-only behavior, and the transcript-scan dialog path stays a dormant fallback that lights up automatically if a future provider flushes a pendingtool_use.- An auth-flow descriptor generalizes login without generalizing prematurely.
The provider declares an
AuthFlowdescriptor surfaced onGET /providers:oauth-code(start returns a URL, the operator pastes a code back — claude today),oauth-redirect(localhost browser redirect;Instructionscarries operator guidance such as an SSH port-forward),api-key(a vault credential reference injected at spawn — schema only in this issue, no implementation behind it), andexternal(managed outside lab; status-only). The routes become per-provider —/providers/{id}/auth/{status|login/start|login/code| logout}— the old claude-only names are gone (the SPA ships with the backend). The typed login sentinels (ErrDialogNotAnswerable,ErrInvalidReply,ErrInvalidCode,ErrLoginTimeout) and the SSE event move to theproviderpackage (claudecode keeps thin aliases so itserrors.Ischains hold), sohttpapiimports no concrete provider; the event is the provider-genericprovider.auth.changedcarrying a provider id. - Seeding rides provider-declared metadata behind one generic seeder.
internal/seederno longer hardcodes claude shapes. The provider declares aSeedMeta(context-file name, skills dir, exclude entries, incogni scrub/seeded-path patterns) that the one generic seeder consumes; claude-code declares today's values. The split stays coherent — the provider's ownSeedWorkspaceseeds its trust/MCP/attribution grants,SeedMetadescribes what lab layers on top of any agent CLI. Byte-identity of the seeded claude-code worktree is pinned from both sides by goldens (the seeder asserts "generic seeder + these values == the pre-#51 bytes" against its own inlined literals;claudecode/seedmeta_test.gopins the declaration to the same literals), so a drifting declaration fails a test rather than silently reshaping worktrees. - Frontend copy is provider-neutral.
DisplayName()drives every user-facing string (the auth card title, "needs input", the open affordance already comes from fallback_open).ClaudeAuthCardbecomes the descriptor-drivenProviderAuthCard— it renders flows, never providers. A grep-guard test (providerNeutral.test.ts) fails on a hardcoded "Claude"/"claude.ai" string in a component;reposvc.DefaultProvider = "claude-code"stays (it is a default, not a leaked string). - Contract footnotes, documented as seam obligations (issue decision 10).
Replywhileworkingis legal and best-effort — claude queues mid-turn input natively; an adapter whose TUI drops it MUST return a typed error the UI can surface, never silently lose the text.Interruptrecipes are adapter-owned and MUST be hazard-checked against the real TUI (some TUIs quit or discard work on double-Esc/Ctrl-C). Each future adapter brings its owninternal/compatpin + fixture-driven port, and may not weaken claude's.
Live verification (2026-07-08, Claude Code 2.1.198)¶
This section is the ADR's strongest evidence and mirrors ADR-0020's
live-activation framing. The generalized dialog recipes were driven end to end
through the production Reply/AnswerDialog path against the real pinned
binary — TestCompat_Live_askUserQuestionRecipe and
TestCompat_Live_exitPlanModeApproval (internal/compat/live_recipes_test.go,
gated on LAB_COMPAT_LIVE=1), each spawning a real claude, raising the pinned
dialog shape, playing the answer through the real recipe with production pacing,
then reading the transcript back and asserting the recorded answers match the
intent — the same comparison the backstop makes. Doing so found and fixed four
real defects that snapshot tests could not have caught (three in the dialog
recipes, §7; one in the reply path, §6):
- Up wraps — it does not clamp. The pre-#51 normalize-to-top climb (and the
first cut of the new recipes) assumed
Upclamps at the top row; it does not. OneUpfrom the top option jumps to the last option, and on the review screen from "Submit answers" to "Cancel" — so[Up][Enter]on the review screen silently landed on Cancel and discarded the whole form (the model then re-asked). Fix: no climb — every freshly presented picker already opens on row 0, lab always answers a fresh picker in one shot, so the recipes walk purely downward; the review step is a bare[Enter]. - Enter on the empty free-text row DECLINES the whole dialog. The shipped
"Other" recipe sent
Enterbefore the text. ButEnteron the empty "Type something." row is recorded astoolUseResult:"User rejected tool use"— it declines. Fix: type-first —[Down×idx][PasteText][Enter]; the text fills the row inline, thenEntersubmits it. The same type-first path drives the plan feedback row. - Enter on a multi-select option row toggles — it never commits. The
shipped multi-select recipe confirmed with a bare
Enterafter theSpacewalk, butEnteron an option row toggles exactly likeSpace(observed: it flipped the last selection off) and never commits. Commit is a dedicated unnumbered "Submit" row below the options. Fix: walk onto Submit,Enterthere. - Reply's Enter raced a multi-line paste and was dropped. A ~600-char
7-line reply pasted with an immediate
Entersat unsubmitted in the composer indefinitely; theEnterraced the composer's paste processing. Fix: pace the submitEnterbykeyDelay(250ms) just like the dialog ops — short replies never hit the window because it scales with paste size.
Two non-bug live findings shaped the design rather than fixing a bug:
- A multi-select answer's recorded label order is not option-index order.
Live, toggling Apple then Cherry recorded
"Cherry, Apple". The backstop therefore compares the comma-joined labels as a set (split, sort, compare), or a correct answer would false-warn. - The plan picker's row-0 TUI label varies with session state under the very
same spawn flag ("Yes, and use auto mode" → "Yes, auto-accept edits" — the
bundle builds the row set from the permission mode + auto-mode state). Lab
therefore surfaces its own stable semantic labels —
Approve — auto-accept edits,Approve — review each edit,Reject — refine the plan,Reject with feedback— pinned by index (rows 0–1 approve, rows 2–3 reject), never a mirror of the drifting TUI text; the recipe and the backstop both couple only to the index. Separately, an undriven picker self-resolves after a 60safkTimeoutwithanswers:{}— the backstop classifies "timed out although lab sent an answer" as a warning, and no recorded intent as silent.
The live JSONL transcripts are captured as compat fixtures
(testdata/transcript-{askuserquestion,exitplanmode,multiselect-timeout}-live-2.1.198.jsonl).
These tests exist so the next Claude Code upgrade re-verifies the §7 recipes with
one command instead of a by-hand TUI session.
What this does not do¶
- No Codex or Gemini adapter. This issue ships the generalized seam and one
adapter (claude-code) brought up to it. #2 tracks the Codex implementation;
Gemini transcript durability, Codex
notifypayload richness, and pasted-slash execution on their TUIs are their own verification spikes. - No protocol-mode / push adapter. Answering still rides the pinned send-keys recipe into the live picker; a bridge/WebSocket-native answer channel stays deferred (ADR-0020).
- No lab-owned message store. Read-through is reaffirmed (ADR-0016) — the
transcript is the provider's durable record; lab persists only
runs.transcript_path. - No TUI pane scraping. The never-scrape rule is universal. Synthesized
option rows and the command builtins are write-side pinned constants, like
the keystroke recipes — nothing reads the pane. (The live tests watch the pane
with
capture-paneonly to know when the picker is up; that is the verification harness observing its own probe, not a production read.) - No
api-keyauth implementation, no per-provider state metadata, no new schema migration. Theapi-keyflow is a descriptor kind only; the five-state vocabulary is unchanged; the schema change is the additiveDialog.Questions/answers[], no migration.
Status¶
Accepted. Amends ADR-0016 (the universal schema gains dialog kinds +
multi-question forms; command echoes render as user text + lifecycle stdout; the
clear/epoch obligation becomes a documented seam duty; the no-slash-command-UI
stance is superseded) and ADR-0020 (DialogHooker → LiveSignals;
multi-question and plan approval are now answerable). Extends the AgentProvider
seam (ADR-0008) with four mandatory methods (DisplayName, AuthFlow,
SeedMeta, Commands) and follows ADR-0017's optional-capability pattern for
LiveSignals and ADR-0021's "provider declares, lab renders generically"
pattern for AuthFlow/SeedMeta. internal/compat gains §10 (the builtin
command catalog) and live-re-verifies §5/§6/§7 against 2.1.198 with the new
live_recipes_test.go. No database migration. Unblocks #2 (Codex): a new
provider implements the seam, declares its metadata, brings its own compat pin,
and gets the chat, auth, seeding, and command surfaces with zero refactor of the
generic core.
Considered options¶
- Implement a second provider now to prove the seam. Rejected for this
issue: a real Codex adapter carries its own live-verification spikes (headless
remote-connections, transcript durability) that would dwarf the seam work and
gate it on OpenAI's behavior. Proving the seam by construction — every
claude assumption lifted into declared metadata or a written obligation, the
grep-guard and the no-
claudecode-import rule enforcing it — is the cheaper, stronger proof, and #2 becomes a pure add. - Scrape the pane for the plan picker and multi-question rows. Rejected for the same reason ADR-0016/0020 rejected it — the pane widget is the most volatile surface. Synthesizing the rows as pinned constants (verified live, index-coupled, backstop-guarded) keeps the never-scrape rule intact.
- A dedicated seam clear method. Rejected: clearing is a normal command
(
/clear,/new), so it rides the command catalog and the plain-text reply path; the only thing lab must know is the effect (a new transcript identity), captured as a seam obligation rather than a method every provider must special-case. - Mirror the TUI's own plan-approval labels. Rejected on the live evidence: row 0's label drifts with session state under the same spawn flag, so mirroring would desync the SPA's buttons run to run. Stable lab-owned semantic labels, coupled to the index, do not.
- Fully generalize auth (implement all four kinds). Deferred: only claude's
oauth-codehas a real backend today. The descriptor is designed for all four so the SPA card is data-driven now, butapi-key/oauth-redirect/externalare schema-only until a provider needs them — the ADR-0021 discipline of declaring the seam without over-building it.
Consequences¶
- The seam is now the contract a second provider ports against: implement
AgentProvider(withDisplayName/AuthFlow/SeedMeta/Commands), optionally advertiseDeepLinker/ConnectingReporter/LiveSignals, declare metadata, and the chat, auth, seeding, and command surfaces follow with no generic-core change. Each future adapter brings its owninternal/compatpin and fixture-driven port and may not weaken claude's — the existing pins are a floor. internal/providergrows the dialog kinds +Question/QuestionAnswer, theCommandSpeccatalog, theAuthFlow/SeedMetadescriptors, the generic auth sentinels +provider.auth.changed, andLiveSignals(renamed). Theprovidertestfakes implement every new surface.internal/seederis one generic seeder driven bySeedMeta;internal/httpapiimports no concrete provider; the SPA's copy is descriptor-driven and grep-guarded.internal/compat§10 joins the upgrade checklist alongside §5–§9, and the live recipe tests make an upgrade's dialog re-verification a single command.- The backstop is advisory-only and in-memory: a mismatch warning is deterministic per parse while the intent exists, but disappears after a lab restart (later seqs shift down one). Accepted — the alternative is a storage surface for a rare warning.