Adding an agent harness
A runtime (agent harness) is a RuntimeRegistry::Bundle — a struct with twelve slots, one for each
seam where driving a vendor CLI differs. The bundle is plain data — a struct of class references
Zimmer looks up at runtime.
Bundle = Struct.new( :runtime, :air_adapter_name, :cli_adapter_class, :retry_strategy_class, :transcript_source_class, :transcript_normalizer_class, :mcp_status_detector_class, :prompt_contribution_class, :config_preparer_class, :config_post_processor_class, :auth_provider_class, :mcp_credential_writer_class, keyword_init: true)Core code never says “Claude.” It asks RuntimeRegistry.for(runtime).
What ships
Section titled “What ships”| Slot | claude_code | codex |
|---|---|---|
air_adapter_name | "claude" | "codex" |
cli_adapter_class | ClaudeCliAdapter | CodexRuntimeAdapter |
retry_strategy_class | ClaudeRetryStrategy | CodexRetryStrategy |
transcript_source_class | ClaudeTranscriptSource | CodexTranscriptSource |
transcript_normalizer_class | ClaudeTranscriptNormalizer | CodexTranscriptNormalizer |
mcp_status_detector_class | McpLogPollerService | CodexMcpStatusDetector |
config_post_processor_class | ClaudeMcpConfigPostProcessor | CodexConfigTomlPostProcessor |
mcp_credential_writer_class | ClaudeMcpCredentialWriter | CodexMcpCredentialWriter |
prompt_contribution_class | ClaudeRuntimePromptContribution | nil |
auth_provider_class | nil | nil |
config_preparer_class | nil | nil |
The three registries that bypass the bundle
Section titled “The three registries that bypass the bundle”This is the thing that will catch you. Besides the Bundle, there are three separate .for case
statements you must also register in:
RuntimeAuthProvider.for(runtime) # + add to RUNTIMESRuntimePromptContribution.for(runtime)RuntimeLoginDriver.for(runtime)And a fourth registry that isn’t a .for at all: ModelCatalog::MODELS[runtime], which resolves its
own keys so a model catalog can exist before a bundle does.
The interfaces
Section titled “The interfaces”RuntimeCliAdapter (mixin)
Section titled “RuntimeCliAdapter (mixin)”execute(prompt:, session_id:, working_dir:, mcp_config_path:, images:, append_system_prompt:, model:, auto_compact_window:) # → {pid:, stderr_log_path:}resume(session_id:, working_dir:, prompt:, images:, mcp_config_path:, append_system_prompt:, model:, auto_compact_window:) # → same shapebinary_name # → Stringcommand_summary(session_id:, prompt:, mcp_config_path: nil, resume: false) # must start with binary_nameretry_strategy(session:, file_system:, process_manager:, rate_limit_tracker:, logger:)disallowed_tools # default []runtime_env_vars # default {}Enforced by test/contracts/runtime_cli_adapter_contract_test.rb, which asserts keyword-set
equality via instance_method(:execute).parameters. Add your adapter (and a mock) to
RuntimeCliAdapterContractTest::ADAPTERS.
Also include CliSpawnEnv — don’t reimplement env scrubbing.
Retry strategy: the five predicates
Section titled “Retry strategy: the five predicates”normal_completion_exit?(status)context_length_error?(stderr_log_path:)failed_resume_recovery_needed?(stderr_log_path:)api_error_for_retry?(working_dir:)auth_recovery_needed?(working_dir:) # ← the one the docs forgetTranscriptSource
Section titled “TranscriptSource”transcript_directory(working_directory:)resume_transcript_path(session:, working_directory:) # default nil = "no single-file restore"locate(session:, working_directory:)read(path)parse_events(serialized)discover_subagent_files(working_directory:, session_id:)mcp_log_paths(working_directory:)find_main_transcript(transcript_directory:, session:) # ← NOT on the base classTranscriptNormalizer
Section titled “TranscriptNormalizer”normalize(raw_event, session:, transcript_index:) # → [OpenTranscripts events]extract_session_id(raw_event)mints_own_session_id? # Codex: true. Claude: false.extract_subagent_links(raw_event)extract_subagent_spawns(raw_event)mints_own_session_id? is a correctness landmine. If you return true for a runtime whose
session id Zimmer generates, forked sessions collide on the unique session_id index.
The rest
Section titled “The rest”RuntimePromptContribution—guidelines_bullets,clarifying_questions_suffix,project_instructions_filename(CLAUDE.mdvsAGENTS.md),delivered_via_file?,system_prompt_filename.RuntimeConfigPostProcessor— a template-method base. Implementconfig_path,parse_config,empty_config,servers_map,build_server_entry,resolve_and_rewrite!,serialize_config.RuntimeMcpCredentialWriter—write!(working_directory:, credentials:),credential_key_for(server_name, server_config).RuntimeAuthProvider—accounts,current_account,select_account_for,refresh!,inject_for_session!,activate!,rotation_interval.RuntimeLoginDriver—command,env(config_dir),parse_verification(buffer),completion_mode(:poll|:paste),capture!(config_dir, account),credentials_ready?.
The checklist
Section titled “The checklist”RuntimeRegistry— newBundle, add toBUNDLESandLABELS.ModelCatalog::MODELS["<runtime>"]— exactly one entry withdefault: true.- CLI adapter —
include RuntimeCliAdapter+CliSpawnEnv. Identical kwargs.<runtime>_stderr.log,pgroup: true, NULL stdin/stdout. - Retry strategy — all five predicates.
- Transcript source + normalizer — including
find_main_transcriptandmints_own_session_id?. - Prompt contribution → register in
RuntimePromptContribution.for. - Config post-processor.
- MCP credential writer.
- MCP status detector.
- Auth provider →
RuntimeAuthProvider.forandRUNTIMES. Login driver →RuntimeLoginDriver.for. Dockerfile.base— pin the CLI and the matching@pulsemcp/air-adapter-<runtime>. Add toCliStatusService::CLI_TOOLS.- Add the adapter to
RuntimeCliAdapterContractTest::ADAPTERSand write a mock intest/support/.
What the existing runtimes get wrong
Section titled “What the existing runtimes get wrong”Codex is the honest reference implementation, and it is incomplete:
Other known gaps:
- Shared code still says “Claude.”
TranscriptPollerServicelogs “Waiting for Claude CLI to create transcript directory…” for every runtime. ELICITATION_SESSION_IDis Claude-only — elicitations silently no-op on Codex.Ao::ExtensionRegistry.spawn_env_contributionsis Claude-only — extension env contributions are unreachable from Codex, despite the hook receiving aruntimecontext.SubagentTranscript#open_transcript_eventshardcodesClaudeTranscriptNormalizer.