Skip to content

Plug-and-play provider architecture: modularize and standardize all provider implementations #394

Closed

Description

@Julius-o1

Goal

Move Agent Code from two hardcoded providers (claude, codex) to a plug-and-play provider architecture: adding a provider means writing one headless package + one src/providers/<kind>/ module + registering it, with the compiler (not grep) enforcing completeness. This issue is the founding map: it catalogs, with file:line specificity, every place the current architecture resists that goal, so the refactor can be planned as a PR sequence instead of discovered incrementally.

Do not underestimate this scope. The registry seed looks clean, but the audit below found that it abstracts only ~8 capabilities while hundreds of sites across main, renderer, preload, MCP, the headless packages, and the translation library branch on the literal pair — including the entire command surface, provider switching (a core feature), spawn UI, prompt delivery, conditions, resume identity, and transcript ingestion. Several failure modes for a hypothetical third provider are silent (details in §4).

Companion issue: Juliusolsson05/agent-transcript-parser#5 — the translation library must move from pairwise A↔B converters to neutral-hub codecs (A↔C↔B with a lossless intermediate transcript). Provider switching depends on it; it is a hard prerequisite for N>2 providers.


1. What already works (keep and extend)

  • src/shared/types/providerKind.ts:35 — AGENT_PROVIDER_KINDS = ['claude','codex'] as const is a true single source of truth with derived types and runtime guards. The design intent (compile-enforced checklist via Record<AgentProviderKind, …> registries) is exactly right.
  • src/shared/types/providerConfig.ts — the main/renderer config split (hard boundary, no combined type) survived a real tsconfig-boundary failure and must be preserved.
  • src/providers/registry.main.ts / registry.renderer.ts / registry.renderer.capabilities.ts — throwing lookups, exhaustive records.
  • ProviderConditionOutlet.tsx:41 — condition views are already registry-dispatched (fixed from an old binary ternary).
  • renderToolUse/renderToolResult committed-row dispatch through the registry (Block.tsx:146,166) with the documented undefined-vs-null contract.
  • OrchestrationBridge.ts threads kind opaquely — never branches on it.
  • src/main/incident/ — zero provider references; fully neutral.
  • Proxy/debug disk layout (~/.config/agent-code/proxy/<project>/<session>/<ts>/proxy-events.jsonl) is shared by construction; downstream readers/retention are provider-neutral.
  • All three headless packages have convergently evolved the same skeleton (EventEmitter class, semantic/screen/committed channels each re-emitting on a catch-all event, turn_started/turn_delta/turn_completed with source+confidence tags, start()/stop()) — the empirical candidate for the standardized package contract.

2. The contract gap: what the registry actually abstracts

MainProviderConfig abstracts exactly four operations: createSession (returning unknown!), listSessions, getProjectDir, resolveTranscriptPath (+optional listAllSessions). RendererProviderConfig abstracts four more: conditionViews, renderToolUse, renderToolResult, TileLeaf — and TileLeaf is a fake abstraction: both providers register the same concrete component (registry.renderer.ts:16-24) which then branches on kind internally.

The real behavioral contract lives in:

  • AgentSessionLike (sessionManager.ts:153-179) — enforced only by a cast at the spawn site (:414-428), with per-provider named optional methods (awaitPastePlaceholder Claude-only, awaitReadyForPrompt Codex-only, resolveCondition routed Claude-only at :944).
  • An unchecked event-name contract (§1f of the audit): started/pty-data/screen/jsonl-entry/jsonl-error/process-state/conditions/semantic-event/exit — names verified nowhere; a missing exit is only mitigated by kill()'s belt-and-suspenders removeAllListeners.
  • Type-level cross-coupling: ManagerEvents.screen is typed with ScreenSnapshot imported from the Claude provider (sessionManager.ts:6), and jsonl-entry as JsonlEntry from claude-code-headless (:8, also jsonlCoalescer.ts:1) even though Codex ships CodexRolloutLines through the same channel.
  • started.projectDir has ambiguous semantics by design (Claude: per-cwd transcript dir; Codex: global sessions root).

3. Union re-declaration sprawl

At least four independent provider unions don't derive from AGENT_PROVIDER_KINDS: src/mcp/shared/orchestrationTypes.ts:3, src/mcp/shared/agentTranscriptTypes.ts:1, src/shared/performance/types.ts:58, src/shared/parsers/extractAssistant.ts:16 — plus zod enums (createBuiltInMcpServer.ts:83,416), renderer-local unions (features/feed/types.ts:5, PathPickerModal.tsx:27), and dozens of inline 'claude' | 'codex' literals across sessionManager.ts:187, sessionIndex.ts:53,114,267, sessions/transcriptPaths.ts:9, providerSwitch/*, worktreeActivity/types.ts:3, providerResumeCommand.ts:8, and every provider-touching preload method (preload/api/{session,provider,types}.ts). SetupToolId (shared/types/setup.ts:11-16) is a second, parallel provider enumeration for binaries. ~40 ?? 'claude' defaults scatter across rehydrate/session/pane/undoClose paths.

4. Silent failure modes for a third provider (found by audit — these are the scariest)

  1. Spawn falls through to a plain terminal: sessionManager.ts:389 gates on the two literals; an unregistered-in-that-if kind silently spawns a shell.
  2. submitPrompt silently no-ops while returning ok: createBuiltInMcpServer.ts:877-958 has if codex … / if claude … with no else — orchestration prompt delivery for a third kind does nothing and reports success.
  3. Unknown persisted kind silently drops the pane on restore: rehydrate.ts:463 (meta.kind ?? 'claude') passes unrecognized strings through; getMainProvider throws; rehydrate catches per-session and the pane vanishes (:511-515). No version/migration guard exists (useBootstrap.ts:133 is bare JSON.parse).
  4. Prompt indexing parses a third provider as Codex: sessionIndex.ts:299-302 ternary.
  5. JSONL ingestion parses it as Claude: renderer routes entries via isCodexRolloutEntry shape-sniffing (useIpcSubscriptions.ts:1177-1191), duplicated in initialHistory.ts, history.ts, and previewModel.ts — four copies of the same fork.
  6. Spawn UI never shows it: PathPickerModal.tsx:230-243 and NewAgentPlacementOverlay.tsx KIND_OPTIONS are hand-written lists, so a registered provider compiles but is unspawnable from the UI.
  7. Unknown kinds become Claude in the composer: TileLeaf.tsx:136-137 (kind==='codex' ? 'codex' : 'claude') — a third provider inherits Claude's paste thresholds and submit protocol.
  8. Mixed fold policy: foldEvent.ts turn-replacement policy is kind-gated with different defaults per arm — a third provider gets Claude behavior in some arms and Codex in others.

5. Per-provider protocols with no capability model

  • Prompt delivery (the deepest one): Codex = readiness-gate BEFORE paste + one atomic \x1b[200~…\x1b[201~\r write; Claude = paste, await [Pasted text #N] placeholder, then separate \r (createBuiltInMcpServer.ts:877-958, useComposerKeybinds.ts:143-289, claudePaste.ts). Readiness probes are provider-named duck-typed methods and IPC channels (claude:await-paste-placeholder; awaitCodexReadyForPrompt). Needs a promptDelivery capability: { readinessGate?, strategy: atomic-paste | paste-confirm-submit | api-call } — noting opencode's prompt() is an acknowledged HTTP call, so "submit" must be modeled as possibly unacknowledged.
  • Conditions: ProviderConditionSnapshot is a closed discriminated union (providerConditions.ts:155,179,184); selectors.ts:77-154 hardcodes all 8 condition-kind strings across four selector functions; applyConditionSnapshot (useIpcSubscriptions.ts:203-267) is a hand-written two-provider else. sessionManager.resolveCondition:944 routes actions to Claude only — Codex/third-provider condition actions dead-end. Condition actions are raw keystrokes (ConditionPtyAction.data) — meaningless for an API provider (opencode answers permissions via idempotent HTTP with requestID). The conditions core is vendored into each headless package by scripts/sync-conditions-core.mjs with a hardcoded two-entry TARGETS list.
  • Image attachments: the whole pipeline is Claude-only with no capability flag (ClaudeDraftImage, useClaudeImagePaste.ts:71,98, fs:saveClaudeImage, claudeImageCache.ts, ComposerInput pill strip, provider-switch carrying images only toward Claude).
  • Optimistic echo / queues: two bespoke models (Claude durable queue-operation records vs Codex local placeholders + idle-clear invariant, queueInvariants.ts:44); a third provider gets neither — submit appears to do nothing until the transcript lands.
  • Resume identity: three unrelated mechanisms (Claude filename uuid in per-cwd dir; Codex rollout uuid + 120s fork watcher + lineage overlap ≥3; opencode server session id). providerSessionIdSource: 'jsonl-entry'|'proxy-header'|'resume-request' (workspace/types.ts:105) encodes Claude-specific id provenance; durability predicates branch on it (providerSessionIdentity.ts:13,150,202). Live id capture hardcodes both rules side-by-side (useIpcSubscriptions.ts:1177-1191).
  • History pagination markers: historyLoader.ts:47-60 ternary (Claude stable uuid vs Codex synthesized timestamp:id).
  • Rewind anchors: {kind:'claude';uuid} | {kind:'codex';userMessageIndex} unions leak from agent-transcript-parser through providerSwitch/rewindSession.ts into preload (api/provider.ts:75-98) and renderer (latestUserPrompts.ts, RewindToPromptModal.tsx). Fixed by the neutral anchor in agent-transcript-parser#5.
  • Screen extraction / activity: extractAssistant.ts:16,32 two-parser switch; glyphs ⏺/› (sessionDisplay.ts:27-29); labels defaulting to 'Claude Code' (labels.ts:22-31).
  • Subagents: join keys are tool names 'Agent' vs 'spawn_agent' (ConversationRow.tsx:23-24); watcher assumes Claude's sidecar file layout; Codex is rollout-first (subagents/index.ts:45,91-94).

6. Provider switching — core feature, currently a 2×2 special case

src/main/providerSwitch/ switches by negation (switchProvider.ts:35-38: sourceKind === 'claude' ? switchClaudeToCodex : switchCodexToClaude — there is no target parameter), with pairwise converters from agent-transcript-parser, per-provider resume sanitizers app-side, deep Claude envelope synthesis (:130-206, hardcoded claude-opus-4-7, permission-mode: 'bypassPermissions'), and per-provider file-layout writers. duplicateSession.ts and rewindSession.ts follow the same pattern. UI is semantically binary: provider.ts:46-55 computes target as the other one; BulkProviderSwitchModal.tsx is a two-direction picker.

Dependency: agent-transcript-parser#5 (neutral-hub codecs). App-side work: explicit-target switch API (switch(sessionId, targetKind)), N-way switch/duplicate UI, neutral rewind anchors, sanitizers absorbed into codecs.

7. Commands and UI enumeration — full rewrite required

Everything command- and label-shaped that mentions a provider must become registry-derived instead of hand-enumerated. This is a big, user-visible surface, not a cleanup:

  • features/workspace/commands/sessionCommands.ts — ~25 sites: every session command gates kind==='claude'||kind==='codex' and labels via ternary.
  • features/workspace/commands/paneCommands.ts:278,286,400,425 — "Split with Codex" style commands; useKeybinds.ts:636,641 hardcoded split-with-codex chords.
  • Command palette (CommandPalette.tsx:231,243,271,688,918) — focusedProvider defaults, resume listing/execute ternaries.
  • Spawn surfaces: PathPickerModal.tsx provider toggle, NewAgentPlacementOverlay.tsx KIND_OPTIONS — must map over registered providers with registry-supplied labels/glyphs/descriptions.
  • Bulk switch modal (binary direction picker) → target-provider picker.
  • All label/glyph/toast/waiting-text ternaries (providerLabel, providerGlyph, "waiting for Codex…/Claude Code…", "Switched to Codex/Claude" — renderModel.ts:233, Feed.tsx:936, provider.ts:68,104, etc.).
  • Per-provider commands the registry must be able to contribute (e.g. a provider-specific action set) rather than the command files knowing providers.

Registry additions this implies: glyph, shortLabel, resumeCommand(id) (kills providerResumeCommand.ts:8-24), spawn descriptions, and a way to enumerate providers for UI (AGENT_PROVIDER_KINDS-driven, which spawn UI currently ignores).

8. Headless package standardization (the packages themselves)

The audit of claude-code-headless / codex-headless / opencode-headless found:

  • Copy-drift with lost knowledge: HeadlessTerminal.ts near-identical copies (Codex's is missing the documented sync-output stall bug); JsonlTailer.ts (Codex has an older snapshot, missing freshSinceMs recovery); CommittedChannel error-event naming — Claude renamed 'error'→tail_error for a Node-crash reason, never back-ported to Codex, and opencode independently picked history_error. Three error-event names for one concept.
  • ~200-line live-owner state machines duplicated in both PTY classes, already behaviorally diverged (Claude has reconciling eviction; Codex doesn't).
  • Semantic vocabulary drift: SemanticSource unions differ per package; block identity differs (blockIndex numeric vs opencode blockId string); delta field names differ (textSoFar vs fullText); tool results live on three different channels; stream_phase unions differ; publisher APIs differ structurally (strict lifecycle-enforcing named methods vs lenient whole-event publish).
  • Conditions core is generated into two packages by sync-conditions-core.mjs (hardcoded TARGETS).
  • SSE frame parsing implemented three times (Claude sseFraming.ts, inline in CodexResponsesAdapter.ts:687-741, opencode SseClient.ts).
  • What opencode proves (it has no PTY, no xterm, no keystrokes, no transcript files): the universal contract must NOT include PTY constructor params, screen snapshots as required data, keystroke-typed actions, spinner-scrape activity, live-owner arbitration, or file-based committed truth. It must model: turn lifecycle + block events + phases + usage + errors (semantic), a UI/overlay channel (screen in name only), committed entries with provider-defined provenance + pull-based history hydration, structured permission/question actions with stable request ids, and activity as a fact with provider-graded confidence.

Decide: extract a shared @agent-code/headless-core package (channels, terminal, tailer, conditions core, SSE framing, live-owner machine) vs. a written contract + conformance test suite. The sync-script approach has already demonstrably failed (drift + unported fixes).

9. Remaining hardcoded-site inventory (by subsystem)

Main: sessionManager.ts:187,360,389,416,800,834,918,944,963; ipc/session.ts:137-146,161,184,207,226,245; ipc/provider.ts:29,45; sessionIndex.ts (~10 sites incl. registry-bypassing package imports at :4-6 and a third copy of CODEX_ROLLOUT_RE at :218); historyLoader.ts:25,47-60; transcriptPaths.ts:7; worktreeActivity/* (2nd CODEX_ROLLOUT_RE); agentTranscripts/AgentTranscriptReader.ts:167,231,386,455,490,535,577,608-624 (content-sniffing detectProvider); setup/{binaryResolver,prerequisites,toolchain}.ts (both providers are required:true launch-blocking prerequisites — a plug-and-play world needs per-provider optional gating); storage/claudeImageCache.ts (whole file); shared/runtime/projectDir.ts (Claude storage math in shared).

Renderer: full catalog in the audit — headline items are foldEvent.ts policy gates (:288,337,365-380,585-589,710,734), useIpcSubscriptions.ts ingestion forks (:203-267,855-878,1177-1191,1354-1524), useComposerKeybinds.ts:143-289, conditions/selectors.ts:77-154, renderModel.ts:46,280-295,427 (committedClaudeMessageTurnIds — provider baked into a field name), BlockRow.tsx:243-261,395-402 (live rows bypass the registry that committed rows use), misplaced provider parsers under workspace/{claude,codex}/ (~540 LOC of rollout mapping in shell code), renderUnits.ts dual id spaces, and every feature UI listed in §7.

Preload: api/types.ts:105,274,294,478, api/session.ts:102-139, api/provider.ts:22-98, api/fs.ts:46-47 — plus provider-named IPC channel names themselves.

MCP: orchestrationTypes.ts:3, agentTranscriptTypes.ts:1, zod enums :83,416, guard :607, submitPrompt:877-958, tool descriptions saying "Claude or Codex" (:115,149,181).

10. Suggested phasing (each phase shippable; order matters)

  1. Kill union sprawl — mechanical: derive every provider union/zod enum/inline literal from AGENT_PROVIDER_KINDS; route the ~40 ?? 'claude' defaults through one documented DEFAULT_PROVIDER (or explicit per-site handling); add a persisted-kind validation + migration/version guard on workspace restore (fixes the silent pane drop). No behavior change.
  2. Widen MainProviderConfig/RendererProviderConfig to absorb the known capability set: session-class contract (replace createSession: unknown + cast with a real AgentSession interface incl. event map), promptDelivery strategy, historyMarker, providerSessionId capture rule, transcript-entry codec (kills the 4-copy shape-sniff fork), binary/setup descriptor (absorbs SetupToolId coupling; per-provider required gating), resumeCommand, glyph/labels, image-attachment capability flag, optimistic-echo/queue policy, spawn-arg builder (dangerous-mode flag, MCP config injection, resume argv shape).
  3. Conditions v2 — open the closed snapshot union (namespaced kinds + generic record shape), make selectors capability-driven, route resolveCondition through the registry for all providers, replace keystroke-typed actions with structured actions (keystrokes as one implementation), and fix the vendored-core distribution (real shared package or per-package dependency). Aligns with Extend conditions system to consume semantic events (proxy channel) alongside screen parsing — unlocks broad coverage (/model, /add-dir, many more) #99 (semantic-first conditions).
  4. Commands & UI enumeration rewrite (§7) — registry-derived commands, spawn UI, labels, glyphs, palette entries; delete every hand-written provider list.
  5. Provider switching on the neutral hub — blocked by agent-transcript-parser#5; explicit-target switch, N-way UI, neutral rewind anchors, sanitizers into codecs.
  6. Headless contract standardization (§8) — shared core extraction or conformance suite; unify semantic vocabulary (one source/confidence/block-identity/delta-field scheme), one error-event name, one tool-result home; back-port the known lost fixes either way.
  7. Prove it with opencode — wire packages/opencode-headless (already exists, fully unwired, with its own architecture proposal in research/10-architecture-proposal.md) as the third provider. This is the acceptance test: it is maximally adversarial to leftover PTY/screen assumptions.

11. Acceptance criteria

  • Adding a provider touches ONLY: AGENT_PROVIDER_KINDS, a new src/providers/<kind>/ module implementing the widened configs, a headless package satisfying the contract/conformance suite, one codec in agent-transcript-parser, one upstream-versions.json entry (if CLI-based), and one conditions-module registration. Everything else — spawn UI, commands, palette, labels, switch/duplicate/rewind, prompt indexing, history, ingestion, MCP orchestration schemas — picks it up from registries with zero edits.
  • Zero kind === 'claude' / 'codex' literals outside src/providers/<kind>/ and the provider packages (enforced by review, not tooling — per repo conventions).
  • All silent failure modes in §4 become loud: unknown kind → explicit error surfaced to the user; unsupported capability → visible degradation, never wrong-provider behavior.
  • Same-provider behavior is regression-free through phases 1–4 (they are refactors, not redesigns).
  • Workspace restore handles both directions of version skew for SessionMeta.kind.

12. Related issues

13. Open questions

  1. Is terminal ever becoming a provider, or permanently special-cased? (Current code deliberately excludes it from AgentProviderKind — keep that.)
  2. Should the widened capability surface live on the two config types or as a third SharedProviderDescriptor (pure data: labels, glyphs, id rules) importable from both sides without boundary risk?
  3. Conditions core distribution: real npm-style shared package inside the monorepo vs. the sync script? (The sync script has already caused drift.)
  4. How far to take headless unification — shared headless-core package vs. contract + conformance tests only? (Shared code fixes drift but couples release cadence of the submodules.)
  5. Per-provider optionality in setup: today claude AND codex are both launch-blocking required:true. Plug-and-play implies "installed providers" as a user-level concept — does the setup gate become dynamic?
  6. Dispatch-to-native-terminal mode (planned escape hatch) requires provider sessions to stay host-agnostic — the widened AgentSession contract must not bake in the Electron window/IPC host.

Activity

  1. Juliusolsson05 commented on Jul 6, 2026

    @Juliusolsson05
    Owner

    Progress checkpoint (2026-07-04, autonomous session)

    Merged: Phase 1 (#395) · Phase 2a (#397) · 2b (#398) · 2c-1 (#399) · 2c-2 (#400) · 2c-3 (#401) · 2c-4 (#402) · Phase 3a (#403) · Phase 4 (#404) · Phase 5a (#405). Library track: neutral-hub skeleton (atp#6), semantic decode slice 1 (atp#7), cross-provider translate seam with byte-proven equivalence (atp#8) — the A↔C↔B API is consumable now; pointer bumped in #405. Headless drift back-ports: codex-headless#25.

    Registry capability surface as of today: typed AgentSession contract + neutral wire types; createTranscriptEntryMapper + extractProviderSessionId (kind routing with shadow comparison); deliverPrompt (main); composerSubmit + supportsImageAttachments + usesOptimisticUserEcho; identity descriptor (glyph/shortLabel/spawnDescription/resumeCommand/splitShortcutKey); conditionPolicy; setup descriptors (registry.setup.ts); open condition snapshot union + conditionStateByKind; explicit-target switchProvider.

    Remaining: 5b (N-way switch UI, neutral rewind anchors, switch engine onto codecs), 3b (structured condition actions, codex resolveConditionAction parity, pending*-mirror deletion), 6 (headless contract/conformance + vocabulary unification), 7 (wire opencode — acceptance test).

    Runtime smokes still owed on next real app run: composer paste routing (route:* paste-debug events), routeShadowMismatches staying zero in feed-debug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions