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 {}Plus a class-level half, because callers that never spawn still need it:
self.stderr_log_filename # → "<runtime>_stderr.log". REQUIRED — the default raises NotImplementedErrorself.spawn_error_class # → your error class; defaults to RuntimeCliAdapter::SpawnErrorself.cli_label # → "Codex CLI", for operator-facing errors; defaults to the class nameself.stderr_log_path(dir) # provided: dir + stderr_log_filename, nil for a blank dirself.validate_working_dir!(dir) # provided: refuses nil/blank, raising spawn_error_classstderr_log_filename is what Session#stderr_log_path reads, so skipping it doesn’t fail
quietly — it raises NotImplementedError the first time a session on your runtime is resumed,
interrupted, or terminated. Build your spawn-time path from it too (self.class.stderr_log_path),
so the name your process writes and the name every caller reads cannot drift.
validate_working_dir! must run at the top of execute and resume, before anything joins onto
working_dir. A nil working directory does reach adapters — that was #183 — and without the guard
it dies inside Process.spawn with a message that names no argument.
Enforced by test/contracts/runtime_cli_adapter_contract_test.rb, which asserts keyword-set
equality via instance_method(:execute).parameters, the stderr-filename shape, and the
working-dir guard’s accept/reject behavior. Add your adapter (and a mock) to
RuntimeCliAdapterContractTest::ADAPTERS. An adapter provided by an extension lives outside that
list, so call assert_runtime_cli_adapter_contract from the extension’s own test instead.
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:)All five are declared in runtime_cli_adapter.rb’s contract docstring and asserted by
test/support/runtime_cli_adapter_contract.rb
(RuntimeCliAdapterContractAssertions::RETRY_STRATEGY_PREDICATES). Implement fewer than five and
the contract test fails by name — which is the point: the auth-recovery path is reached only on a
session that is already failing, so a missing predicate used to surface as a production
NoMethodError at the worst possible moment (#56).
auth_recovery_needed? is the one to notice. It is what routes an exit into
AuthRecoveryCoordinator (adopt → rotate → park) rather than into a plain failure, so a runtime
that returns a flat false is not “safely defaulting” — it is opting out of credential recovery
entirely. See the Codex note under What the existing runtimes get wrong.
TranscriptSource
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:)find_main_transcript is declared on the abstract base class and raises NotImplementedError
there, like the rest of the required surface. TranscriptPollerService calls it on every poll, so a
source that skipped it used to NoMethodError on its first poll instead of failing at the seam
(#56).
For Claude Code it delegates to TranscriptFileLocator, which prefers
<session_id>.jsonl. Before the runtime has minted that id there is no id to match on, so it falls
back to the most recently modified non-agent-*.jsonl file that was written after the session
started — the mtime floor is what stops a working directory still holding an earlier session’s
transcript from handing this session someone else’s conversation (#57). If your runtime needs a
fallback of its own, scope it the same way; returning nil means “not written yet”, which callers
already treat as a waiting state.
TranscriptNormalizer
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.
Tracked in #96.
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_secrets!,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, androtate_for_quota!(triggered_by:, reason:). The last one is the pool’s only move-off-this-account seam: both the quota path andAuthRecoveryCoordinatorgo through it, andreasonis what distinguishes theirAccountRotationEventrows. A runtime that doesn’t pool accounts inherits the base class’s no-op, which parks its sessions instead of rotating them.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. Declareself.stderr_log_filename(<runtime>_stderr.log) and guardexecute/resumewithvalidate_working_dir!.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:
ELICITATION_SESSION_IDis Claude-only — elicitations silently no-op on Codex.Zimmer::ExtensionRegistry.spawn_env_contributionsis Claude-only — extension env contributions are unreachable from Codex, despite the hook receiving aruntimecontext.SubagentTranscript#open_transcript_eventshardcodesClaudeTranscriptNormalizer.