Provider seam v2: generalize the agent-CLI contract (dialog kinds + multi-question, slash-command catalog, clear semantics, auth descriptor, provider-declared seeding) #51
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#51
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
Evolve the
AgentProviderseam so lab's chat surface works with any agent CLI (Codex CLI, Gemini CLI, …), not only Claude Code — without implementing any new provider in this issue. Deliverables: the generalized seam contract, the claude-code adapter brought up to it (including two new answerable dialog shapes), the new slash-command surface end to end, the auth/seeder/frontend generalizations, and the ADR + compat documentation.All decisions below were settled with the operator on 2026-07-08 — do not re-litigate them. Ambiguities beyond them are yours to resolve in the spirit of the pinned choices.
Grounding (read first)
internal/provider/provider.go— the seam:AgentProvider, the universal message schema (text|tool|dialog|lifecycle, statesidle|working|needs_input|question|ended), optional capabilities (DeepLinker,ConnectingReporter,DialogHooker).internal/provider/claudecode/— the only implementation: transcript mapper (chat.go,chat_types.go,chat_dialog.go), send-keys recipes (interact.go), dialog hook spool (dialogspool.go).internal/chat/— the provider-generic tailer and chat brain (per-session locks, spool overlay, rotation follow).docs/adr/0008, 0016, 0017, 0020, 0021 — the seam's history. This issue amends 0016 and 0020.internal/compat/— the pinned-coupling registry (Claude Code 2.1.198). Every new fragile coupling added here gets its own section, fixture-driven and live-verified.Pinned decisions
1. Read-through stands (reaffirms ADR-0016). Every provider runs its real TUI under tmux; the adapter maps the provider-native transcript into the universal schema on read. No lab-owned message store. Replies land in native history via the provider's own recording.
2. Clear history: no seam method. Three parts:
roleper entry;role=clearmarks the provider's native clear command (claude/clear; codex would be/new). The frontend may bind a "New conversation" affordance to it; execution rides the normal Reply/paste path.transcript_path→transcript_id) whenever the provider clears context. A provider that clears in place must synthesize a new epoch. Document this onLocateTranscript/ReadTranscriptcontract comments.isLocalCommandEchoblocks (<command-name>/<command-message>) map to a visible user message showing the command (e.g./clear) instead of being dropped; non-empty<local-command-stdout>maps to a follow-up lifecycle message. Command echoes stay excluded from state derivation — the echo drivingderiveStateto "working" was the stuck-composer root cause; add a regression test for exactly this.3. Dialog schema: kinds + multi-question.
DialoggainsKind(question | plan | approval) andQuestions []Question(each: header, question text, options with label+description, multiSelect, free-text-allowed).DialogAnswergrows per-question answers.Answerablestays a per-dialog property. API (GET /runs/{id}/messagespending_dialog,POST /runs/{id}/answer) and SPA follow.tool_idmatch, re-read under lock) stay generic.AskUserQuestion(today: render-degraded hint). Render the full form; the UI collects all answers in one submit; the adapter plays the sequenced picker recipe (per-question normalize-to-top climb incl. trailing synth rows, Down×idx, Space toggles, Enter, paced bykeyDelay). The inter-picker transition must be captured live before trusting it.ExitPlanMode) (today: plan text + deep-link hint).Kind=plan, plan body rendered as markdown, approve/reject options synthesized as pinned constants, answered via picker recipe.tool_use/tool_resultlands in the transcript, the adapter compares recorded answers against what was intended and emits a lifecycle warning message on mismatch, so a blind-sequence desync is visible in the chat.4. Conversational state: unchanged, best-effort. The 5-state vocabulary is the contract. Baseline duty for every adapter: distinguish
working/idle/endedfrom its transcript;needs_input/questionarrive only where a live signal channel exists. No per-provider state metadata.5. Slash-command catalog: new mandatory seam method.
CommandSpec:Name,Description,ArgHint,Source(builtin|project|user), optionalRole(onlycleardefined for now; extensible), and a chat-safe flag so the adapter curates out commands that would strand the TUI in a picker lab cannot see. claude-code implementation: a pinned builtin table (compat section, version-verified) merged with a scan of its native custom-command locations —.claude/commands/, seeded skills under.claude/skills/(user-invocable ones), user-level dirs. Served asGET /api/v1/runs/{id}/commands(worktree-dependent, hence per-run), briefly cached server-side. Frontend: autocomplete when the composer input starts with/(filter as you type: name, description, argHint), plus the optional role=clear affordance.6. Live-signals capability generalization. Rename/reshape
DialogHookerinto a provider-neutral capability (e.g.LiveSignals) with the same contract (spawn-time setup returning settings payload + argv injection, pending-dialog read, blocked-state read, cheap change signature, spool sweep). claude-code keeps its PreToolUse/PostToolUse/Notification spool implementation. Providers without a verified structured channel simply omit the capability — honest degradation: never-scrape is universal, auto-approval spawn defaults keep dialogs rare, a residual blocked state degrades toneeds_input+ the copyable tmux attach affordance.7. Auth surface generalization (design the descriptor, keep claude the only implementation).
/api/v1/providers/{id}/auth/{status|login|code|logout};internal/httpapistops importingclaudecodefor typed errors (move sentinel errors to the genericproviderpackage or map behind the seam).GET /api/v1/providers: kind ∈oauth-code(start returns a URL, user pastes a code back — claude today),oauth-redirect(localhost browser redirect; descriptor carries operator instructions, e.g. SSH port-forward),api-key(vault credential reference, injected at spawn via the existing materialization mechanism — schema only in this issue, no implementation needed),external(managed outside lab; status-only).provider.auth.changedwith a provider id) — rename cleanly, SPA ships with the backend.ClaudeAuthCardbecomes a descriptor-drivenProviderAuthCard.8. Seeding behind the seam.
internal/seedercurrently hardcodes claude shapes (.claude/skills,CLAUDE.local.md, exclude entries, attribution-scrub patterns forclaude/anthropic.com). Make these provider-declared metadata (context-file name, skills/prompts layout, exclude entries, scrub patterns) consumed by one generic seeder; claude-code declares today's values. Keep the split between providerSeedWorkspaceand the generic seeder coherent — one obvious place for each concern.9. Provider-neutral frontend copy.
RunChat.tsxhardcodes "Claude is waiting for your reply.", "open it in claude.ai", claude-styled chrome; drive user-facing strings from provider metadata (display name; the open affordance already comes fromfallback_open).reposvc.DefaultProvider = "claude-code"stays (it is a default, not a leak).10. Contract footnotes (document as seam contract comments):
Replywhileworkingis legal and best-effort per TUI (claude queues natively); an adapter whose TUI drops mid-turn input returns a typed error the UI can surface.internal/compatpin + fixture-driven port; nothing in this issue may weaken the existing claude pins.Suggested implementation order
DialogAnswer,Commands,DialogHooker→LiveSignalsrename, epoch/clear + footnote contract comments;providertestfakes grow every new surface./runs/{id}/commands, per-question answer payload, generalized auth routes + descriptor + event, typed-error decoupling.docs/adr/),compat.mdupdates, CONTEXT.md only if new operator-facing vocabulary is introduced.Acceptance criteria
AgentProvidercarriesCommands; Dialog kinds +Questions[]+ per-question answers flow seam → API → SPA;DialogHookerrenamed provider-neutral; all fakes updated; noclaudecodeimport outside its package except the wiring incmd/lab.transcript_idreset behavior unchanged.GET /runs/{id}/commands(builtins + project commands + seeded skills for claude-code); role=clear binding works; chat-unsafe commands are curated out./providers/{id}/auth/...with descriptor-driven SPA card; no claude-typed errors inhttpapi; SSE event renamed.makegates (fmt/vet/tests, web build) green.Out of scope
notifypayload richness; pasted-slash execution on their TUIs).land-pr audit — PR #53 "feat: provider seam v2 — generalize the agent-CLI contract" → PASS
What was checked:
lab/20260708-1652(60b018e) sits exactly one commit atop currentorigin/main(64ee80c) — base unmoved, no conflict possible. Title is Conventional Commits (feat:); body carries a workingCloses #51.nativeCI job (ci.yml) runs on every PR and branch protection enforces it green at merge — not re-run here.ci-nixis path-gated to*.nix/flake.lock/go.mod/go.sum; this diff touches none, so its skip is legitimate. The PR additionally documents local gates green (go test with the known pre-existing tmuxx sandbox failure; 452 vitest, lint, format, build) and live recipe verification against pinned Claude Code 2.1.198, plus three in-session adversarial reviews whose findings were fixed with regression tests.AgentProvidercarriesCommands(ChatSafe curation, role=clear); DialogKind/Questions[]+ per-questionAnswers[];DialogHookerfully renamed toLiveSignals(only a historical comment mentions the old name);AuthFlowdescriptor +SeedMetaon the interface.LocateTranscript/ReadTranscript; reply-while-working and interrupt-hazard footnotes present as contract comments.internal/httpapiimports noclaudecode; sentinels live in the genericproviderpackage; SSE event isprovider.auth.changed.TestParseTranscript_commandEchoNeverDrivesState(the post-/clear stuck composer),TestParseTranscript_localCommandEchoRenders, liveTestCompat_Live_askUserQuestionRecipe/TestCompat_Live_exitPlanModeApproval.SeedMetapre-push hook gets a:no-op body withsh -nparse checks inhook_golden_test.go; "New conversation" is gated onstate() !== 'working'+!composerLocked()with tests for working/locked/ended.providerNeutral.test.tsgrep-guard: no allowlist, includes a can-never-go-blind sanity check.internal/compattests importclaudecode— pre-existing and by design (compat is the claude pin registry; the live-recipes harness must drive the real adapter); all production layers are clean. (2) Cosmetic: the genericprovider.ErrDialogNotAnswerablemessage hardcodes "open it in claude.ai" — accurate for the only current provider, but a second adapter wrapping this sentinel would inherit the copy; tweak when a second provider lands.Verdict: PASS — awaiting the operator's explicit merge go-ahead.