Skip to content

Adding an agent harness

A runtime (agent harness) is a RuntimeRegistry::Bundle — a struct with fourteen 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,
:artifact_bridge_class, :auth_provider_class, :mcp_credential_writer_class,
:usage_ingestor_class,
keyword_init: true
)

Core code never says “Claude.” It asks RuntimeRegistry.for(runtime).

Slotclaude_codecodexpi
air_adapter_name"claude""codex""pi"
cli_adapter_classClaudeCliAdapterCodexRuntimeAdapterPiRuntimeAdapter
retry_strategy_classClaudeRetryStrategyCodexRetryStrategyPiRetryStrategy
transcript_source_classClaudeTranscriptSourceCodexTranscriptSourcePiTranscriptSource
transcript_normalizer_classClaudeTranscriptNormalizerCodexTranscriptNormalizerPiTranscriptNormalizer
mcp_status_detector_classMcpLogPollerServiceCodexMcpStatusDetectorPiMcpStatusDetector
config_post_processor_classClaudeMcpConfigPostProcessorCodexConfigTomlPostProcessorPiMcpConfigPostProcessor
mcp_credential_writer_classClaudeMcpCredentialWriterCodexMcpCredentialWriterPiMcpCredentialWriter
artifact_bridge_classNullRuntimeArtifactBridgeNullRuntimeArtifactBridgePiAirBridge
usage_ingestor_classTokenUsageIngestionServiceCodexTokenUsageIngestionServicePiTokenUsageIngestionService
prompt_contribution_classClaudeRuntimePromptContributionnilPiRuntimePromptContribution
auth_provider_classnilnilnil
config_preparer_classnilnilnil

Claude and Codex share a shape: their AIR adapter writes the config, and the runtime supplies MCP, hooks and plugins itself. Pi does neither, which is what makes it the interesting third column — see Pi is the runtime that supplies nothing.

artifact_bridge_class is the one slot where Pi holds a real class and the other two hold a null object: it writes the AIR hooks and plugins config Pi’s extensions read, which Claude’s and Codex’s AIR adapters already handle for them. See Pi is the runtime that supplies nothing.

usage_ingestor_class sweeps the runtime’s transcripts into the token-spend ledger (costs). The contract is .new(modified_since:) and #call, returning something that responds to #session_rows and #to_s; TokenUsageIngestionJob runs every non-nil one on its cron and isolates a failure to the ingestor that raised it. It is a slot rather than a conditional because where a runtime records what it spent has no common answer, and all three runtimes answer it differently: Claude Code writes a host-global ~/.claude/projects tree keyed on the API’s own requestId; Pi writes into the clone, and is therefore read back out of sessions.transcript because the clone is reaped; Codex writes a date-partitioned rollout tree that Zstandard-compresses itself when a session finishes, and reports tokens with no per-call identifier and no model, so both the key and the model attribution are constructed as the rollout is streamed. nil remains legal in this slot and means “this runtime’s spend is not ingested yet”; no registered runtime is nil today.

Two pi slots are worth reading in full — one because it is emphatically not nil, the other because it is nil for a reason of its own rather than by that convention:

  • mcp_status_detector_class — Pi writes no per-server MCP log files, so Claude’s log poller has nothing to read. It held NullMcpStatusDetector on the further argument that pi-mcp-adapter “routes every server through one mcp proxy tool, so a transcript shows mcp being called and never names the server behind it”. That was true of an older adapter and is not true of the pinned one. 2.32.1 registers a namespace-proxy tool per server, mcp__<server> (namespaceProxyName), and the bare mcp proxy takes the server verbatim in its connect argument. PiMcpStatusDetector mines both. Until it did, every Pi session’s servers read pending forever while they were connected and answering — which is how a working Pi session got reported as one whose MCP was dead. Re-derive a claim like this against the pinned version rather than inheriting it; the slot must still never be nil, for the reason below. TranscriptPollerService#initialize calls .new on this slot with no nil check, so a nil here raises NoMethodError on every poll of every session on the runtime, before any MCP-specific guard can run. That is the general rule: a slot some caller dereferences must never be nil — a runtime with nothing real to put there supplies a null object. test/contracts/runtime_bundle_slot_contract_test.rb enforces this for every registered runtime and every unconditionally-dereferenced slot.

    Filling the slot is necessary and not sufficient. A detector also has to work, and the null object’s whole job is the inherited half of the interface: TranscriptPollerService#poll_mcp_logs calls update_session_mcp_status after every poll, and for Pi that call is the only thing that seeds the pending placeholders keeping a configured server visible in mcp_servers_status instead of reading as “not configured”. NullMcpStatusDetector shipped including McpStatusPersisting but not DatabaseRetry, so that inherited call raised NoMethodError on every poll of the first Pi session to run in production — a filled slot, a successful construction, and a broken runtime. McpStatusPersisting now includes DatabaseRetry itself, so no includer can repeat it, and the contract test polls and persists for every runtime rather than only constructing.

  • mcp_credential_writer_class — Pi keeps MCP OAuth tokens inside the pi-mcp-adapter extension’s own state, and Zimmer held this slot nil on the reading that it therefore could not deliver one. It can: the adapter documents a plaintext entry it imports from <oauth dir>/sha256-<server>/tokens.json, and PiMcpCredentialWriter stages exactly that, so no registered runtime has a nil writer today. The nil case remains part of the seam, and the slot may be nil because every caller is guarded: RuntimeRegistry.mcp_credential_writer_classes compacts the list (its callers instantiate every class it returns — the credential-retire path, i.e. while a credential is already failing, and RefreshMcpOauthTokensJob, which has no session and so reads every runtime’s store before it refreshes), and McpOauthCredentialInjector asks #credential_store? first. That second guard is load-bearing rather than defensive: McpOauthController#reinject_and_resume calls injection and the resume service inside one rescue, so a raise from injection would skip the resume and leave a session parked on an OAuth gate permanently un-resumable.

    “Every caller” is a wider set than the write paths, and reading it as narrowly as “the one that writes the file” is what broke the first Pi session with an OAuth-credentialed MCP server attached. The writer also owns the credential key (#credential_key_for), so resolution needs it too: the key on each ResolvedMcpCredential, and the key McpOauthRuntimeReconciler reads the on-disk store under. #check_credentials_status — the pre-spawn OAuth gate, which never writes anything — went through that second one and dereferenced the nil, so the session died at the gate with NoMethodError: undefined method 'credential_key_for' for nil before producing a line of output. Every path that needs a runtime credential key now short-circuits on #credential_store?, and the injector’s contract tests assert both the gate and injection for every registered runtime rather than for Claude alone.

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 RUNTIMES
RuntimePromptContribution.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.

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 shape
binary_name # → String
command_summary(session_id:, prompt:, mcp_config_path: nil, resume: false) # must start with binary_name
retry_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 NotImplementedError
self.spawn_error_class # → your error class; defaults to RuntimeCliAdapter::SpawnError
self.cli_label # → "Codex CLI", for operator-facing errors; defaults to the class name
self.stderr_log_path(dir) # provided: dir + stderr_log_filename, nil for a blank dir
self.spawn_artifact_paths(dir) # provided: [stderr_log_path(dir)]. Override to add more
self.validate_working_dir!(dir) # provided: refuses nil/blank, raising spawn_error_class

stderr_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.

spawn_artifact_paths is every file your spawned process writes into the working directory. ForkSessionService deletes them from the copied clone, because they describe the run that produced it and not the one about to start. The default covers the stderr log; override and append to super if your runtime writes more. CodexRuntimeAdapter does, for its --json event log — that file names the source session’s Codex thread, and a fork that kept it would resume someone else’s conversation. The contract test asserts this for every adapter, so a runtime that returns a relative path or forgets its own stderr log fails the suite rather than leaking state into forks.

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.

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.

transcript_directory(working_directory:)
per_working_directory_transcript_root # default nil = "not sweepable"
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).

per_working_directory_transcript_root is the other default-nil method, and nil there is a refusal to be swept, not a gap. It answers “is there a root under which every child is a transcript directory attributable to the one working directory that produced it” — which is what lets OrphanTranscriptDirectoryCleanupJob enumerate that root and delete the children whose cwd is gone. Only Claude Code answers non-nil (~/.claude/projects). Codex writes every session into one date-partitioned tree that ignores the cwd, so deleting a child would take other sessions’ rollouts with it; Pi writes inside the clone, so its transcripts already go when the clone does. Override it only if your runtime’s layout genuinely has that one-directory-per-cwd shape — and derive the name through transcript_directory, never by re-implementing the slug. See The transcript directory outlives the clone.

resume_transcript_path is the one with a meaningful default. It answers “where do I write the stored transcript so --resume reads the whole conversation”, and the base class returns nil — “this runtime cannot be restored from a single deterministic path”. Every caller that restores a transcript to disk (AgentSessionJob, UnarchiveSessionService, ForkSessionService) skips the write on nil and treats that as success, so a runtime that does not override it is left alone rather than handed a file it will never read. See Writing a transcript back to disk.

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.

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)
conversation_record?(raw_event) # conversation, or bookkeeping?

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.

conversation_record? is the second one. Every recovery path asks RuntimeConversationPresence whether the runtime has written a conversation before it abandons one, and this method is what that question resolves to. Answer it with a deny-list of the bookkeeping your runtime writes into the same file — Claude Code’s ai-title, Codex’s session_meta — so a record type you have not met counts as conversation. Get the polarity backwards and a session’s real history is thrown away; leave it unimplemented and it raises NotImplementedError out of a recovery path. See A transcript with no conversation in it.

  • RuntimePromptContributionguidelines_bullets, clarifying_questions_suffix, project_instructions_filename (CLAUDE.md vs AGENTS.md), delivered_via_file?, system_prompt_filename.
  • RuntimeConfigPostProcessor — a template-method base. Implement config_path, parse_config, empty_config, servers_map, build_server_entry, resolve_secrets!, serialize_config.
  • RuntimeMcpCredentialWriterwrite!(working_directory:, credentials:), credential_key_for(server_name, server_config).
  • RuntimeAuthProvideraccounts, current_account, select_account_for, refresh!, inject_for_session!, activate!, rotation_interval, and rotate_for_quota!(triggered_by:, reason:). The last one is the pool’s only move-off-this-account seam: both the quota path and AuthRecoveryCoordinator go through it, and reason is what distinguishes their AccountRotationEvent rows. A runtime that doesn’t pool accounts inherits the base class’s no-op, which parks its sessions instead of rotating them.
  • RuntimeLoginDrivercommand, env(config_dir), parse_verification(buffer), completion_mode (:poll | :paste), capture!(config_dir, account), credentials_ready?.
  1. RuntimeRegistry — new Bundle, add to BUNDLES and LABELS.
  2. ModelCatalog::MODELS["<runtime>"] — exactly one entry with default: true.
  3. CLI adapter — include RuntimeCliAdapter + CliSpawnEnv. Identical kwargs. Declare self.stderr_log_filename (<runtime>_stderr.log) and guard execute/resume with validate_working_dir!. pgroup: true, NULL stdin. If the runtime writes anything else into the working directory, add it to self.spawn_artifact_paths so forks shed it — and if it prints a structured event stream on stdout, capture that rather than sending it to NULL (see CodexEventStream).
  4. Retry strategy — all five predicates.
  5. Transcript source + normalizer — including find_main_transcript, mints_own_session_id? and conversation_record?.
  6. Prompt contribution → register in RuntimePromptContribution.for.
  7. Config post-processor.
  8. MCP credential writer — the whole contract, not just #write!. If the runtime refreshes MCP OAuth tokens itself (Claude Code and Pi both do), #read_runtime_credentials is what keeps a rotating provider’s credential alive, and #enumerable_store? / #runtime_key_for say how it is addressed. See MCP OAuth.
  9. MCP status detector.
  10. Usage ingestor — how the runtime’s spend reaches session_token_usages. Leaving it nil is allowed and means the runtime’s cost is not tracked; say so in limitations rather than leaving it to be discovered from a zero.
  11. Auth provider → RuntimeAuthProvider.for and RUNTIMES. Login driver → RuntimeLoginDriver.for.
  12. Dockerfile.base — pin the CLI and the matching @pulsemcp/air-adapter-<runtime>. Add to CliStatusService::CLI_TOOLS — and note the contract on check_auth: it is either a Ruby callable, or a shell command whose argv names a real subcommand of the binary. An agent CLI typically takes a bare positional prompt, so an argv that matches no subcommand is billed as inference on a two-minute cron (#536). If the runtime needs vendor extensions to reach MCP/hooks (Pi does), pin those too and declare them in a registry the Dockerfile is asserted against — see PiExtensions.
  13. Add the adapter to RuntimeCliAdapterContractTest::ADAPTERS and write a mock in test/support/.

Claude Code and Codex both arrive with an MCP client built in (Claude Code with hooks and plugins too), so Zimmer’s job for them is to write config files into a shape the runtime already understands. Pi ships a skills mechanism and nothing else. Three consequences are worth knowing before you read PiRuntimeAdapter.

air prepare pi writes no MCP config. @pulsemcp/air-adapter-pi is deliberately skills-only — it injects .pi/skills/ and records mcpServers: [] and hooks: [] in its manifest. So PiMcpConfigPostProcessor is the only config post-processor that writes the server table rather than adjusting one: it seeds .mcp.json from ServersConfig before the shared injection/retarget pipeline runs. Without that seeding a Pi session would start with none of the servers it was configured with, and the failure would surface only at the first tool call.

MCP, hooks and plugins arrive as Pi extensions. PiExtensions is the registry, and PiRuntimeAdapter passes each entrypoint with pi -e <path> from /opt/pi-extensions. pi-mcp-adapter reads the same .mcp.json Claude Code does — that file is a cross-vendor convention, not a Claude private format, which is why the JSON format hooks live in the shared McpJsonConfigFormat module. @tadasant/pi-hooks runs AIR hooks and @tadasant/pi-plugins resolves AIR plugins; both are configured by the files PiAirBridge generates, described next. The adapter is also where Pi’s MCP startup budget lives — it has no env-var equivalent of Claude’s MCP_TIMEOUT, so PiMcpConfigPostProcessor writes requestTimeoutMs onto each stdio entry instead. Being per-entry, it can carry a longer per-server budget a catalog entry declares, which Claude’s one process-wide variable cannot — but not a shorter one, since that key bounds every request on the connection rather than the startup alone. See Timeouts and caching.

Pi supplies no identity either, and the key is OpenRouter’s. Claude Code and Codex both pool subscription accounts that Zimmer rotates; Pi resolves a provider credential per request from the session environment, so PiAuthProvider pools nothing and every one of its methods is a documented no-op. The credential Zimmer supplies is OPENROUTER_API_KEY: every Pi model in ModelCatalog is an openrouter/* id, so one key covers the whole list rather than one per vendor. openrouter is a first-class provider in the catalog bundled with the pinned Pi — no models.json custom-provider entry is needed — and the ids carry the vendor after it (openrouter/anthropic/claude-opus-4.6). The direct anthropic/* and openai/* ids are kept in the catalog and still work wherever ANTHROPIC_API_KEY / OPENAI_API_KEY is set; they are simply not what this deployment feeds. The key is set on the Inference page’s Pi tab, and reaches the process through PiRuntimeAdapter#apply_provider_key, which resolves it from the ${VAR} chain into the spawn environment — see How the key reaches a Pi session for why that step is Pi’s own rather than the session .env writer’s.

Note when refreshing that list: pi --list-models only prints providers whose credential currently resolves, so run it with OPENROUTER_API_KEY set or the openrouter rows are silently absent and the catalog looks much smaller than it is.

Loading an extension is not the same as configuring it, and for hooks and plugins Zimmer has to do both. air prepare pi ignores hook entries outright and honors a plugin only as composition sugar for its skills, so after prepare there is nothing on disk carrying the session’s hooks. PiAirBridge — the pi bundle’s artifact_bridge_class, a no-op for every other runtime — writes a generated mini-catalog into <clone>/.pi/zimmer-air/ and PiRuntimeAdapter names it through PI_HOOKS_AIR and PI_PLUGINS_CONFIG.

Three decisions in that generation are worth knowing:

  • The generated index is the selection. @tadasant/pi-hooks activates every hook in an index it loads — it has no roots concept to filter on — so pointing it at Zimmer’s whole hooks/hooks.json would run every catalog hook in every Pi session. The same trick selects plugins without a PI_PLUGINS env var: the generated plugins.json carries default_in_roots: ["*"] on exactly the plugins the session chose.
  • Naming the files shadows discovery. Both extensions otherwise look for ./air.json in the working directory — which is a clone of whatever repository the session works on, and a repo root is a normal place for an air.json to live (this one has one). Without the explicit variables, cloning a repository would be enough to adopt whatever hooks it declares.
  • A plugin’s skills and MCP servers are left to their real owners. The generated plugin entries carry skills: [] and mcp_servers: [], because air prepare pi already installs plugin skills into .pi/skills/ and PiMcpConfigPostProcessor already writes plugin-bundled servers into .mcp.json with secret resolution and retargeting. pi-mcp-adapter merges both its config files by name, so letting the extension write its own copy would start a second copy of every plugin server.

Only the hooks the session named directly go in the pi-hooks index; anything a selected plugin bundles is subtracted, because pi-plugins dispatches those through its own runner and a hook reachable both ways would be spawned twice per event.

An AIR hook body may speak either runtime’s dialect — from @tadasant/pi-hooks@0.2.0. Claude Code sends {tool_name, tool_input} on stdin and takes context back through hookSpecificOutput.additionalContext; @tadasant/pi-hooks sends {event, toolName, input, content} and takes {"content": ...}, which replaces the tool result rather than appending to it. From 0.2.0 the extension sends both namings and honors both replies, so a body written for either runtime runs on Pi unmodified — which is why PiExtensions::REGISTRY pins 0.2.0 as a floor, not just as the current version. Below it, a body speaking only Claude’s dialect loaded cleanly on Pi, ran, and did nothing, with [pi-hooks] loaded N hook(s) printed either way. PI_HOOK=1 is still set on every hook process, and the catalog’s git-push-ci-reminder still branches on it, because Pi’s replacing content is the one difference no translation can paper over.

An extension’s entrypoint is a TypeScript source file, and it comes from the package. Pi loads .ts extensions directly, so a Pi package’s entrypoint is a file inside the published tarball — pi-mcp-adapter/index.ts, not a compiled dist/index.js. The authoritative value is the package’s own pi.extensions manifest field (npm view <pkg> pi.extensions); read it from there when adding an entry to the registry rather than assuming a build layout.

Getting that path wrong is silent, which is why two checks guard it. PiExtensions#resolved_paths only passes -e for entrypoints that exist — necessary, because pi -e <missing path> makes Pi refuse to start altogether — so a path that can never exist yields a working Pi session with the extension simply absent. Dockerfile.base therefore follows each npm install with a test -f on the entrypoint (npm install reports success as soon as the tarball unpacks, and says nothing about layout), and pi_extensions_test.rb asserts that the path the Dockerfile checks is the path the registry resolves.

Transcript hooks need a per-runtime parser. TranscriptHooks::ToolCallParser.for dispatches on the runtime, and Pi’s shape (toolCall content blocks whose arguments are a real Hash, plus a toolResult message stating isError inline) matches neither Claude’s nor Codex’s. Falling through to the Claude parser would find nothing and make every hook a silent no-op, so TranscriptHooks::PiToolCallParser exists and the dispatcher now warns on an unrecognized runtime instead of quietly defaulting.

Pi’s MCP tools are not individually callable. pi-mcp-adapter exposes one mcp proxy tool that the agent searches and calls through, so a dozen servers cost ~200 tokens instead of thousands. PiRuntimePromptContribution tells the agent this, because one that expects mcp__server__tool to exist will otherwise conclude its servers are missing.

Pi accepts --session-id, so Zimmer’s session id is Pi’s session id. Two things follow that Codex cannot have:

  • mints_own_session_id? is false — there is no runtime-generated id to capture, and no window before the capture during which the transcript cannot be identified.
  • resume_transcript_path is a real path. Pi resolves --session-id against the id inside a session file rather than its filename, so Zimmer restores a stored transcript to one deterministic path and Pi continues appending to its leaf. Codex, whose rollouts are date-partitioned, UUID-named and possibly Zstandard-compressed, returns nil here.

PiRuntimeAdapter also passes --session-dir pointing inside the clone, so each session’s transcripts live in its own working directory. That removes by construction the collision CodexTranscriptSource#fallback_transcript exists to defend against, where two concurrent sessions sharing one rollout tree can read each other’s conversations.

Codex is the honest reference implementation, and it is incomplete:

Other known gaps:

  • Zimmer::ExtensionRegistry.spawn_env_contributions is Claude-only — extension env contributions are unreachable from Codex, despite the hook receiving a runtime context.
  • SubagentTranscript#open_transcript_events hardcodes ClaudeTranscriptNormalizer.

PiRetryStrategy classifies one thing and declines the rest.

A failed model call does not fail the Pi process. Driven against a simulated localhost LLM returning 401, 429, 500 and a 400 context_length_exceeded, a pinned pi 0.84.4 exited 0 every time and wrote the failure into its transcript instead, as an assistant message with stopReason: "error" and an errorMessage led by the HTTP status. Nothing reached stderr. So pi -p exits non-zero for Pi’s failures, and 0 for the provider’s — and a Pi turn whose model never answered used to take ProcessLifecycleManager’s success branch and park the session in needs_input reporting “Process exited successfully”.

PiRetryStrategy#terminal_api_error closes that: when the last conversational entry in the transcript is such an error, the turn is failed with the provider’s own wording rather than parked as finished. An error followed by more conversation is a turn that recovered on its own and is left alone.

context_length_error?, api_error_for_retry? and auth_recovery_needed? still return false, and now for a different reason than Codex’s: the signature is known, but each names a recovery path that is Claude-shaped. ContextLengthRetryService recovers by sending Claude Code’s /compact command, which Pi has no equivalent of; ApiErrorRetryService detects by Claude’s isApiErrorMessage envelope, which Pi does not write; AuthRecoveryService recovers by re-writing the active account’s credentials, and PiAuthProvider pools no accounts to re-write. Making those three transcript-format-agnostic is tracked in #856. Until then a Pi provider failure is failed and named rather than retried — the same posture Codex has. classifies_exits? stays false, so that failure is loud in the session log without becoming a standing page.

There is also no failed-resume pattern to match, and unlike the above that one is correct rather than deferred: Pi’s --session-id creates a missing session rather than failing, so the condition cannot arise. Pi is deliberately absent from RuntimeAuthProvider::RUNTIMES and from RuntimeLoginDriver.for: it has no tokens to refresh and no interactive login flow.