Repository navigation
Description
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 constis a true single source of truth with derived types and runtime guards. The design intent (compile-enforced checklist viaRecord<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/renderToolResultcommitted-row dispatch through the registry (Block.tsx:146,166) with the documentedundefined-vs-nullcontract.OrchestrationBridge.tsthreadskindopaquely — 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/committedchannels each re-emitting on a catch-allevent,turn_started/turn_delta/turn_completedwith 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 (awaitPastePlaceholderClaude-only,awaitReadyForPromptCodex-only,resolveConditionrouted 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 missingexitis only mitigated bykill()'s belt-and-suspendersremoveAllListeners. - Type-level cross-coupling:
ManagerEvents.screenis typed withScreenSnapshotimported from the Claude provider (sessionManager.ts:6), andjsonl-entryasJsonlEntryfromclaude-code-headless(:8, alsojsonlCoalescer.ts:1) even though Codex shipsCodexRolloutLines through the same channel. started.projectDirhas 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)
- Spawn falls through to a plain terminal:
sessionManager.ts:389gates on the two literals; an unregistered-in-that-if kind silently spawns a shell. submitPromptsilently no-ops while returning ok:createBuiltInMcpServer.ts:877-958hasif codex … / if claude …with no else — orchestration prompt delivery for a third kind does nothing and reports success.- Unknown persisted kind silently drops the pane on restore:
rehydrate.ts:463(meta.kind ?? 'claude') passes unrecognized strings through;getMainProviderthrows; rehydrate catches per-session and the pane vanishes (:511-515). No version/migration guard exists (useBootstrap.ts:133is bareJSON.parse). - Prompt indexing parses a third provider as Codex:
sessionIndex.ts:299-302ternary. - JSONL ingestion parses it as Claude: renderer routes entries via
isCodexRolloutEntryshape-sniffing (useIpcSubscriptions.ts:1177-1191), duplicated ininitialHistory.ts,history.ts, andpreviewModel.ts— four copies of the same fork. - Spawn UI never shows it:
PathPickerModal.tsx:230-243andNewAgentPlacementOverlay.tsxKIND_OPTIONS are hand-written lists, so a registered provider compiles but is unspawnable from the UI. - 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. - Mixed fold policy:
foldEvent.tsturn-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~\rwrite; 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 apromptDeliverycapability:{ readinessGate?, strategy: atomic-paste | paste-confirm-submit | api-call }— noting opencode'sprompt()is an acknowledged HTTP call, so "submit" must be modeled as possibly unacknowledged. - Conditions:
ProviderConditionSnapshotis a closed discriminated union (providerConditions.ts:155,179,184);selectors.ts:77-154hardcodes all 8 condition-kind strings across four selector functions;applyConditionSnapshot(useIpcSubscriptions.ts:203-267) is a hand-written two-provider else.sessionManager.resolveCondition:944routes 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 withrequestID). The conditions core is vendored into each headless package byscripts/sync-conditions-core.mjswith 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-operationrecords 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-60ternary (Claude stable uuid vs Codex synthesizedtimestamp:id). - Rewind anchors:
{kind:'claude';uuid} | {kind:'codex';userMessageIndex}unions leak fromagent-transcript-parserthroughproviderSwitch/rewindSession.tsinto 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,32two-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 gateskind==='claude'||kind==='codex'and labels via ternary.features/workspace/commands/paneCommands.ts:278,286,400,425— "Split with Codex" style commands;useKeybinds.ts:636,641hardcoded split-with-codex chords.- Command palette (
CommandPalette.tsx:231,243,271,688,918) — focusedProvider defaults, resume listing/execute ternaries. - Spawn surfaces:
PathPickerModal.tsxprovider toggle,NewAgentPlacementOverlay.tsxKIND_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.tsnear-identical copies (Codex's is missing the documented sync-output stall bug);JsonlTailer.ts(Codex has an older snapshot, missingfreshSinceMsrecovery); CommittedChannel error-event naming — Claude renamed'error'→tail_errorfor a Node-crash reason, never back-ported to Codex, and opencode independently pickedhistory_error. Three error-event names for one concept. - ~200-line live-owner state machines duplicated in both PTY classes, already behaviorally diverged (Claude has
reconcilingeviction; Codex doesn't). - Semantic vocabulary drift:
SemanticSourceunions differ per package; block identity differs (blockIndexnumeric vs opencodeblockIdstring); delta field names differ (textSoFarvsfullText); tool results live on three different channels;stream_phaseunions 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 inCodexResponsesAdapter.ts:687-741, opencodeSseClient.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)
- Kill union sprawl — mechanical: derive every provider union/zod enum/inline literal from
AGENT_PROVIDER_KINDS; route the ~40?? 'claude'defaults through one documentedDEFAULT_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. - Widen
MainProviderConfig/RendererProviderConfigto absorb the known capability set: session-class contract (replacecreateSession: unknown+ cast with a realAgentSessioninterface incl. event map),promptDeliverystrategy,historyMarker,providerSessionIdcapture rule, transcript-entry codec (kills the 4-copy shape-sniff fork), binary/setup descriptor (absorbsSetupToolIdcoupling; per-providerrequiredgating),resumeCommand, glyph/labels, image-attachment capability flag, optimistic-echo/queue policy, spawn-arg builder (dangerous-mode flag, MCP config injection, resume argv shape). - Conditions v2 — open the closed snapshot union (namespaced kinds + generic record shape), make selectors capability-driven, route
resolveConditionthrough 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). - Commands & UI enumeration rewrite (§7) — registry-derived commands, spawn UI, labels, glyphs, palette entries; delete every hand-written provider list.
- Provider switching on the neutral hub — blocked by agent-transcript-parser#5; explicit-target switch, N-way UI, neutral rewind anchors, sanitizers into codecs.
- 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.
- Prove it with opencode — wire
packages/opencode-headless(already exists, fully unwired, with its own architecture proposal inresearch/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 newsrc/providers/<kind>/module implementing the widened configs, a headless package satisfying the contract/conformance suite, one codec in agent-transcript-parser, oneupstream-versions.jsonentry (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 outsidesrc/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
- Restructure pairwise translation (A↔B) into neutral-hub codecs (A↔C↔B) with lossless intermediate transcript agent-transcript-parser#5 — neutral-hub translation (hard dependency of phase 5)
- Rebuild orchestration context inheritance on a stable provider contract #259 — orchestration context inheritance wants exactly the "stable provider contract" phases 2/6 produce
- Extend conditions system to consume semantic events (proxy channel) alongside screen parsing — unlocks broad coverage (/model, /add-dir, many more) #99 — conditions consuming semantic events (folds into phase 3)
- Claude/Codex interactive question tool calls render as raw JSON — user has to cancel the turn to respond #98 / AskUserQuestion interactive question tool renders as raw TUI picker (screen-parsed) — no native answer affordance #289 — interactive question tools as raw JSON/TUI (capability-model symptoms)
- Handle sub-agent rendering for both Claude and Codex #178 / Codex subagents can be visible in terminal/proxy but absent from runtime subagent state #340 / Subagent rows can remain running for days after semantic text says subagents are done #341 — subagent handling per provider (capability-model symptoms)
- Handle background processes and terminals for both Claude and Codex #179 — background processes/terminals for both providers
- Tool calls without a custom renderer dump raw JSON — add a generalized readable renderer (esp. MCP tools) #287 — generalized tool renderer (interacts with registry
renderToolUsefallback design) - Resume + session search are inconsistent — sometimes show IDs, sometimes cwd names, sometimes prompts; needs to be reasoned through and redone #96 — resume/session-search inconsistency (partly identity-model symptoms, §5)
- Claude prompt submit still inconsistent — detection logic falls behind under load #90 / Submitted prompts can stay queued or screen-only while the agent is already responding #339 — prompt submit reliability (promptDelivery capability, phase 2)
13. Open questions
- Is
terminalever becoming a provider, or permanently special-cased? (Current code deliberately excludes it fromAgentProviderKind— keep that.) - 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? - Conditions core distribution: real npm-style shared package inside the monorepo vs. the sync script? (The sync script has already caused drift.)
- How far to take headless unification — shared
headless-corepackage vs. contract + conformance tests only? (Shared code fixes drift but couples release cadence of the submodules.) - 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? - Dispatch-to-native-terminal mode (planned escape hatch) requires provider sessions to stay host-agnostic — the widened
AgentSessioncontract must not bake in the Electron window/IPC host.
Activity
Providers phase 2a: typed AgentSession contract + neutral wire types
Juliusolsson05 commentedon Jul 6, 2026
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
AgentSessioncontract + 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-targetswitchProvider.Remaining: 5b (N-way switch UI, neutral rewind anchors, switch engine onto codecs), 3b (structured condition actions, codex
resolveConditionActionparity, 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),routeShadowMismatchesstaying zero in feed-debug.docs(extensions): record locked decisions; cut custom feed/tool rende…
fix(opencode): stop hiding OpenCode read failures and harden the adap…
feat(orchestration): let an orchestrator launch a native-TUI child
fix(workspace): place OpenCode history correctly and keep a dead chan…