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.
cargo add tapes-harnessesFull API documentation is on docs.rs. The crate has no feature flags: everything below is always compiled.
The registry
Section titled “The registry”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=SessionsDirlaunchable: claude, codex, opencode, pi, hermesThe five axes
Section titled “The five axes”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’sUser-Agentidentifies the harness:None, or a case-insensitivePrefix.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), orUnsupported.AttributionStrategy— how a capture client recovers a session’s identity:SessionsDir,OpenRollout,SelfAttributing,LifecycleHooks, orNone.TranscriptSource— where the harness’s on-disk transcripts live:ClaudeProjects,CodexRollouts, orNone.PluginDelivery— whether capture needs an artifact installed into the harness:None,BundledExtension(the crate ships the files), orHookManifestTemplates(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.
The registry today
Section titled “The registry today”| 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.
The four ways a harness gets captured
Section titled “The four ways a harness gets captured”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.
Launch recipes
Section titled “Launch recipes”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
Section titled “Attribution”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 completeX-Tapes-*envelope from inside. The client’s job is to preserve what arrives — overwriting a complete inbound envelope withunknownwould 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 recordedstream-jsonstdout instead.
Transcripts
Section titled “Transcripts”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 patch grammars
Section titled “Config patch grammars”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.
Adding a harness
Section titled “Adding a harness”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.