Skip to content

tapes-harnesses

The harness-knowledge crate — the registry and its five axes, the four capture mechanisms, launch recipes, plugin artifacts, attribution lanes, and transcript discovery.

tapes-harnesses is the half of capture that changes because a harness was added. Its membership test is one question: would adding one more harness change this? If yes it belongs here; if no it belongs in tapes-capture, which this crate depends on and which may never depend back.

One naming note before anything else: tapes-harnesses is a crate in the tapes-crates repository, not a repository of its own — see the names.

Terminal window
cargo add tapes-harnesses

Full API documentation is on docs.rs. The crate has no feature flags: everything below is always compiled.

harness::REGISTRY holds one declaration per harness, bundling its id, aliases, User-Agent rule, launch support, attribution strategy, transcript location, and plugin needs. Everything else derives: find resolves a user-typed name through the aliases, for_user_agent resolves a request’s User-Agent, and supported_agents is the launchable subset. Consumers derive their supported-agent lists from the registry rather than hard-coding one, so a new entry appears in their surface without their doing anything — adding a harness is additive. The cursor entry is excluded from that list; find still resolves it.

This compiles and runs against the published crate:

use tapes_harnesses::harness;
fn main() {
// Resolve a user-typed spelling through ids and aliases, case-insensitively.
if let Some(h) = harness::find("Claude-Code") {
println!("{}: launch={:?} attribution={:?}", h.id(), h.launch(), h.attribution());
}
// The launchable subset, derived from the registry rather than restated.
println!("launchable: {}", harness::supported_agents().join(", "));
}

Output:

claude: launch=Recipe attribution=SessionsDir
launchable: claude, codex, opencode, pi, hermes

A Harness declaration is a classification along five orthogonal enums, and they are the vocabulary the rest of the crate speaks:

  • UserAgentMatch — how a request’s User-Agent identifies the harness: None, or a case-insensitive Prefix.
  • LaunchSupport — whether this crate can plan a launch: Recipe (the crate ships one), ConsumerOwned (launchable, but the consumer plans the argv itself), StructuredStdout (the crate plans the argv and the consumer runs the process and saves its stdout), or Unsupported.
  • AttributionStrategy — how a capture client recovers a session’s identity: SessionsDir, OpenRollout, SelfAttributing, LifecycleHooks, or None.
  • TranscriptSource — where the harness’s on-disk transcripts live: ClaudeProjects, CodexRollouts, or None.
  • PluginDelivery — whether capture needs an artifact installed into the harness: None, BundledExtension (the crate ships the files), or HookManifestTemplates (the crate ships manifest templates with consumer-rendered slots).

All five are #[non_exhaustive] on purpose: a new harness can add a variant without a breaking change.

harness User-Agent launch attribution transcripts plugin
claude prefix claude Recipe SessionsDir ClaudeProjects None
codex none Recipe OpenRollout CodexRollouts None
codex-app none Unsupported LifecycleHooks CodexRollouts HookManifestTemplates
opencode none Recipe SelfAttributing None BundledExtension
pi none ConsumerOwned SelfAttributing None BundledExtension
hermes none Recipe SelfAttributing None BundledExtension
cursor none StructuredStdout None None None

Only Claude is identified by User-Agent; the others are recognised by route, launch marker, lifecycle report, or their own envelope. Note that codex-app is a distinct harness, not an alias of codex: it shares Codex’s wire protocol and rollout tree, but it is a long-lived host a consumer configures rather than launches, and its identity arrives through lifecycle hook reports.

Cursor has no proxy traffic. A consumer records agent’s stdout.

This is the distinction to hold on to, because it decides which modules apply to a given harness. It correlates with AttributionStrategy but is not the same axis: that enum says how a request acquires an identity, this says how the traffic is reached at all.

mechanism when it applies plan it with
Launch redirect — point the harness’s base-URL knob at a proxy the harness has such a knob (claude, codex, opencode, hermes) launch
Installed plugin — code runs inside the harness and stamps its own envelope the harness has no such knob (pi) plugin
Lifecycle hooks — a hook plugin reports allowlisted evidence at session boundaries the harness is configured rather than launched (codex-app) plugin::codex_app and config
Structured stdout — the launched harness emits its transcript as NDJSON the consumer owns the process and records stdout (cursor) transcript::cursor_stream

opencode deliberately has both of the first two: its provider endpoints live in a config document a recipe can plan, but it publishes no session file a client could attribute from, so the bundled extension both redirects and stamps the envelope from inside. The registry declares the combination because the two compose.

hermes has both too, split differently: its custom provider in config.yaml is the only redirect (hermes request middleware can rewrite a request’s kwargs but not the client’s base URL), and the bundled plugin only stamps the envelope. HermesRecipe plans a relocated HERMES_HOME holding that config, the plugin, and the plugins.enabled entry hermes requires before it loads any user plugin. hermes’s auxiliary calls (title generation, compression, vision, web_extract, session_search, skills_hub, mcp) bypass the middleware; the plugin stamps them through hermes’s shared auxiliary kwargs builder with x-tapes-harness-metadata {"aux_task": …}. The planned config sets no model.default_headers: hermes would apply them to any provider, so every x-tapes-* header comes from the plugin and only on a request bound for the proxy. The remaining gap: an auxiliary call in a process running several concurrent sessions may attach to the most recent one. While captured, the plugin refuses any main-loop or auxiliary call whose base URL is not on the proxy, with a one-line error naming the host; TAPES_ALLOW_UNCAPTURED=1 lets such calls through, carrying no x-tapes-* header and no nonce. The main-loop refusal rides llm_execution middleware, because hermes swallows exceptions from request middleware.

pi is captured by its extension alone: it redirects pi’s built-in anthropic, openai and openai-codex providers and stamps the envelope, including x-tapes-cwd (percent-encoded exactly as the Rust producer and the hermes plugin encode it) and x-tapes-harness-version. A gateway-served model pi does not ship — a self-hosted OpenAI-compatible model, say — needs one more thing, a models.json entry under openai speaking Chat Completions, and PiRecipe plans it for one session in a relocated PI_CODING_AGENT_DIR alongside the extension and the user’s carried preferences (never auth.json). While routed through a gateway, a pi model whose requests would go anywhere else is refused with a one-line error rather than sent uncaptured.

Cursor uses only structured stdout. transcript::cursor_stream::plan_args builds the agent argv for stream-json mode and refuses flags that would change the output, exit early, detach the process, or resume another session; load checks a saved stream’s system/init event and prepares the upload records. The module never runs the binary.

Recipes are pure planners: they plan an argv prefix, an environment overlay, and any config documents a harness reads from disk. Spawning, materialisation, and cleanup stay with the consumer. The registry records that a harness has a recipe; the consumer constructs it with the arguments only the consumer has — Claude needs one endpoint, codex needs an endpoint plus an auth mode and a provider identity, opencode needs an endpoint per provider plus a model, hermes needs a home and an optional model, and pi needs a recipe only to declare gateway-served models (PiRecipe, with pi_gateway_model to read one from a pi --model pattern).

Plugins, and the gateway environment a launcher sets

Section titled “Plugins, and the gateway environment a launcher sets”

For harnesses captured from inside, the crate ships the artifacts themselves — plugin install in a consumer is a file copy over crate-owned bytes, so no consumer carries its own drifting fork of the asset. Bundled artifacts must be vendor-neutral: an asset that names a vendor stays in that consumer’s repository and gets no registry variant.

An installed artifact reads its instructions from the environment at launch time. These variables make up that contract; the first four are protocol and live in tapes-capture, the rest live here:

variable set by meaning
TAPES_GATEWAY_URL launcher Where to send the harness’s LLM traffic. Unset means “not captured” — the artifact leaves the harness’s own endpoints alone.
TAPES_GATEWAY_SCHEMA launcher Which upstream schema the proxy fronts (anthropic, openai). A display and diagnostic hint; an artifact must not gate the redirect on it.
TAPES_GATEWAY_NONCE launcher The per-launch secret. Read once at load and deleted from the process environment immediately, so the harness’s own subprocesses never receive it.
TAPES_GATEWAY_PROVIDER_ROUTES launcher Set to 1 when the proxy serves each provider on its own route. Unset is the single-upstream shape, which is what a launcher predating this variable gets.
TAPES_GATEWAY_LABEL launcher The product word shown in a harness’s status entry.
TAPES_GATEWAY_LABEL_SUFFIX launcher Appended to the status label after the active schema.
TAPES_GATEWAY_REMEDY launcher The sentence appended to a schema-mismatch warning — the diagnosis is the asset’s, the remedy is the launcher’s, because only the launcher knows which command switches its proxy.
TAPES_ALLOW_UNCAPTURED user Exactly 1 lets a captured pi or hermes session send a request that would bypass the proxy to its own endpoint, uncaptured, with a warning. Unset (or any other value), such a request is refused with a one-line error. Read at request time.

Two more variables travel beside these, and unlike the nonce an artifact must leave them in place: TAPES_LAUNCH_ID and TAPES_LAUNCH_HARNESS_ID, the launch-lineage contract in tapes-capture’s launch module. Every launcher sets them for its child, and a launcher started from inside a captured session reads them as its parent launch — see tapes-capture for the flow and who maps a parent launch to a parent session.

attribution::attribute (and attribute_with_evidence) is the pipeline: one request’s facts in, one attribution outcome out, driving the per-harness primitives in the order that was validated against real traffic. This is the call a capture client makes. The primitives beneath — session-file reads, fork-parent recovery, the rollout watcher — are exposed for tests and unusual clients, grouped per harness (attribution/claude/, attribution/codex/, attribution/codex_app/) with the harness-agnostic pieces shared.

The per-harness lanes match the AttributionStrategy axis:

  • SessionsDir (claude): the harness publishes a PID-indexed session file, so the peer PID of the accepted connection indexes straight to an identity.
  • OpenRollout (codex): nothing is published by PID; identity is recovered from the rollout file a live process holds open, filtered by recency and by the provider the launch configured.
  • SelfAttributing (pi, opencode): the harness stamps its own complete X-Tapes-* envelope from inside. The client’s job is to preserve what arrives — overwriting a complete inbound envelope with unknown would silently re-file those sessions — and to demand the nonce echo before believing it.
  • LifecycleHooks (codex-app): identity arrives as hook reports at session, prompt, stop, and subagent boundaries. There is no launched PID to anchor peer trust on, so the evidence is allowlisted instead.
  • None (cursor): there is no proxy traffic to attribute, so the session id comes from the recorded stream-json stdout instead.

Wire capture yields a complete call inventory but no causal/fork skeleton — that lives only in the harness’s on-disk transcript trees, which the transcript lane uploads. transcript::sweep discovers sessions under a transcript root (including a startup sweep that recovers sessions which began and ended while a client was down), trigger is the push policy, and payload is the ingest payload shape. Delivery, auth, and retry are the consumer’s. Cursor has no tree; transcript::cursor_stream lists .jsonl files under a spool root the consumer supplies and loads each into a CursorTranscript whose payload carries the session id and cwd. The spool root must be a private same-user directory.

config owns format-preserving patch grammars for harness config files — how an installer patches a capture provider into a harness’s own config, idempotently and without disturbing the user’s content. Where launch plans per-process config that dies with the process, config owns the durable install a desktop app or long-lived integration needs.

Teaching this crate about a new coding agent starts with one const in harness.rs; invariant tests tell you what else the declaration implies. Adding a harness walks the whole path, and the harness regression matrix is the CI floor every entry has to stand on.