---
title: Adding a harness
description: Teaching the crates about a new coding agent — the registry declaration, the launch recipe decision, the bar a new attribution lane must clear, and what breaks when the registry changes.
editUrl: https://github.com/papercomputeco/tapes-crates/edit/main/docs/adding-a-harness.md
sourceRepo: papercomputeco/tapes-crates
sourcePath: docs/adding-a-harness.md
lastUpdated: 2026-08-14
---

This crate is client-side harness knowledge: how to launch a coding agent
under a capture proxy, how to work out which session a request belongs to,
where the agent writes its transcripts, and what goes in the `X-Tapes-*`
envelope. Teaching it about a new agent starts in one file — `crates/tapes-harnesses/src/harness.rs` —
and grows from there only as far as that agent's shape demands.

Start by reading `crates/tapes-harnesses/src/harness.rs`. The types there are the vocabulary this
document uses.

The five steps below are the walkthrough. The sections after them are the
reference half: what each field actually causes in a consumer, what a partial
entry costs you, what a new attribution lane has to prove before it can be
trusted, and what breaks when the registry changes.

## Step 1 — declare it

Add a `const` to `crates/tapes-harnesses/src/harness.rs` and put it in `REGISTRY`:

```rust
/// Gemini CLI.
pub const GEMINI: Harness = Harness {
    id: HARNESS_ID_GEMINI,
    aliases: &["gemini-cli"],
    user_agent: UserAgentMatch::Prefix("gemini"),
    launch: LaunchSupport::Recipe,
    attribution: AttributionStrategy::None,
    transcripts: TranscriptSource::None,
    plugin: PluginDelivery::None,
};

pub const REGISTRY: &[Harness] = &[CLAUDE, CODEX, GEMINI, OPENCODE, PI];
```

That declaration is load-bearing, not documentation. `supported_agents()` picks
it up, so it appears in every consumer that derives its launchable list from
here rather than restating one — see "Partial entries and full ones" for which
consumers do that today and which still hardcode theirs; `find()` resolves its
name and aliases from a CLI argument;
`for_user_agent()` routes its traffic; and any launch recipe you add takes its
harness id from `GEMINI.id()` rather than spelling the string again.

`id` must be the `X-Tapes-Harness-Id` value, declared as a constant in
`crates/tapes-capture/src/envelope.rs` alongside the others. That constant is the on-wire name, and
the tapes deriver keys on it — see "The other two places" below.

The invariant tests at the bottom of `crates/tapes-harnesses/src/harness.rs` run against the whole
registry, so `cargo test` will tell you if a declaration is inconsistent: a
duplicated name, an id the envelope never emits, a transcript tree that cannot
be located, a User-Agent rule that claims traffic it should not.

A declaration alone is a real, useful state: launchable, with no attribution
lane yet, its sessions capturing and landing under `harness_id: unknown` until
someone writes one. `OPENCODE` lived in exactly that state for a while — a
recipe and `AttributionStrategy::None` — before its bundled plugin made it
self-attributing. Land the declaration, then add capability.

## Step 2 — a launch recipe, if the agent needs one

`LaunchSupport::Recipe` promises a `LaunchRecipe` in `crates/tapes-harnesses/src/launch/` whose
`harness()` returns your id. Recipes are **pure**: given a `ProxyEndpoint`,
`plan()` returns the argv prefix, the environment overlay, and any config
documents the agent reads from disk. It never spawns a process, writes a file,
creates a temporary directory, or reads the user's home — the consumer owns all
of that, and therefore owns cleanup.

`crates/tapes-harnesses/src/launch/claude.rs` is the smallest complete example (one environment
variable). `crates/tapes-harnesses/src/launch/codex.rs` shows a config-flag grammar with fallible
planning; `crates/tapes-harnesses/src/launch/opencode.rs` shows a recipe that emits a config document.

The line to hold is *harness* knowledge versus *deployment* knowledge. Which
environment variable carries the base URL is yours. What that URL's path prefix
is, which credential to supply, and where to materialise a config file are the
consumer's. A recipe never constructs a route: it receives a fully qualified
endpoint and appends nothing to it.

If the agent exposes no base-URL knob at all, there is nothing for a recipe to
set and capture needs code running *inside* the agent instead. Declare
`LaunchSupport::ConsumerOwned` and see the next step. `PI` is that case.

## Step 2b — a plugin artifact, if capture needs code inside the agent

An agent with no base-URL knob needs a file installed into it — an extension
that registers the agent's providers against the proxy from the inside. Those
files live in `crates/tapes-harnesses/assets/<harness>/` and are declared in `crates/tapes-harnesses/src/plugin.rs` as
`PluginArtifact`s, which the registry hands out through
`PluginDelivery::BundledExtension`. A consumer's `plugin install` is then a file
copy: it resolves the name, takes `harness.plugin_artifacts()`, and writes each
one beneath the user's home.

The bar for putting an asset here is that it names **no vendor**. It reads its
endpoint from `plugin::GATEWAY_URL_ENV` and nothing else — no product-branded
variable, no default endpoint, no "run `<product> ...`" hint in its copy. An
asset that cannot meet that bar stays in the consumer's repository and gets no
variant here; `crates/tapes-harnesses/src/plugin.rs`'s tests enforce the bar for the ones that do.

Two properties an artifact must have, both tested: it is **inert** until
`TAPES_GATEWAY_URL` is set, because it installs globally and would otherwise
change sessions nobody is capturing; and if it stamps an envelope itself, the
header names must match `crates/tapes-capture/src/envelope.rs`, since a rename there would otherwise
silently re-file the agent's sessions as `unknown`.

Where an agent auto-loads *every* file in a global directory — pi does — a
third property follows: **one artifact, one path, identical bytes from every
client**. Two files there are two readers in one process, contending over the
launch nonce and over the same provider registrations, and the loser registers
anyway with no nonce echo, so both clients' sessions file as `unknown` with no
error. Anything a client needs to say differently is read from the environment
of the launch it owns, never shipped as different bytes; and because an earlier
release may have installed a differently-named copy, `PluginArtifact` carries
the names it supersedes and `PluginArtifact::install` removes them. See
`crates/tapes-harnesses/src/plugin/pi.rs`.

An agent whose plugin is installed by the agent's *own* plugin manager, and
whose hook command or identity strings are irreducibly the consumer's, cannot
ship as fixed artifacts. It ships as **templates** instead —
`PluginDelivery::HookManifestTemplates`, where the crate owns the JSON
structure and event set and the consumer renders its command and identity into
slots (see `crates/tapes-harnesses/src/plugin/codex_app.rs`). The vendor-neutrality bar is the same;
only the branding *slots* are consumer-filled.

Rendered manifests are still not an installed plugin: the harness's plugin
manager wants a packaged source directory and is driven by the harness's own
CLI. Both belong to the harness, so both live here too —
`crates/tapes-harnesses/src/plugin/codex_app/manager.rs` renders the marketplace wrapper, names the
paths inside it, and runs the registration, including the CLI quirks (stderr
phrasings that mean "already done", a same-named source pointing elsewhere)
that decide whether an install completes. A consumer writes bytes and prints
words; it does not learn the plugin manager.

## Step 3 — an attribution strategy

Attribution is how a captured session gets a real identity instead of a
synthetic one. Which strategy applies is a property of the agent, not a choice:

- **`SessionsDir`** — the agent publishes a PID-indexed session file it keeps
  current. The peer PID of the accepted loopback connection indexes straight to
  an identity. Claude Code works this way; see `crates/tapes-harnesses/src/attribution/claude/`.
- **`OpenRollout`** — the agent publishes nothing by PID, and identity must be
  recovered from a transcript file a live process holds open, filtered by
  recency and by the provider the launch configured. Codex works this way; see
  `crates/tapes-harnesses/src/attribution/codex/`.
- **`SelfAttributing`** — the agent stamps its own complete `X-Tapes-*`
  envelope from inside itself, through an extension a peer-PID lookup cannot
  see. There is no lane to write: the client's whole job is to *preserve* what
  arrives, which is what `Attributed::stamp` does. `PI` is this shape.
- **`LifecycleHooks`** — the agent is a long-lived host the consumer
  configures rather than launches, so no peer-PID lane can anchor on a
  launched process. Identity arrives instead as allowlisted lifecycle reports
  from a hook plugin installed into the agent; the crate owns the parsed
  shape of those reports, and the consumer owns receiving them. The Codex
  desktop app (`CODEX_APP`) is this shape; see `crates/tapes-harnesses/src/attribution/codex_app/`.
- **`None`** — no client-side attribution yet.

New attribution code goes in `crates/tapes-harnesses/src/attribution/<harness>/`, grouped by harness.
Anything genuinely harness-agnostic does not belong in this crate at all: it
goes to `tapes-capture`, beside `peer_pid` and `peer_trust`, which both
existing lanes share. The test is whether adding one more harness would change
it — if not, it is capture knowledge, and the dependency edge only runs this
way, so putting it here is what makes it unreachable from the other side.

The composition — the sequence that turns one request's facts into one outcome
— lives in `crates/tapes-harnesses/src/attribution/pipeline.rs` and stays there. It exists precisely so
that it does not exist twice: it was validated against real traffic, and a
second implementation would drift silently, mis-attributing sessions in ways
only a parity corpus would catch.

Two rules the existing lanes were built on, learned the hard way:

- **Refuse rather than guess.** A missed attribution heals when the transcript
  is reconciled. A wrong one is permanent and silently corrupts a session's
  shape. Where the evidence does not identify exactly one session, return
  nothing.
- **Absent means "no evidence", never "matches nothing".** A blank header that
  reaches a matcher will refuse every candidate. Filter it out at the edge.

Everything here is best-effort and time-budgeted. An absent field means
unknown, never a sentinel, and a client that cannot attribute a request still
emits a well-formed envelope.

## Step 4 — transcripts

Wire capture yields a complete call inventory but no causal or fork skeleton;
that lives only in the agent's on-disk transcripts, which the transcript lane
uploads. If your agent writes a tree this crate can locate, add a
`TranscriptSource` variant and teach `resolve()` to find it. Honour whatever
home-directory override the agent itself honours, in one place, the way
`CodexRollouts` delegates for `$CODEX_HOME`.

Discovery and packaging in `crates/tapes-harnesses/src/transcript/` are shared. Delivery, auth, and
retry are not — they belong to each consumer, and always will.

## Step 5 — the envelope

If the agent needs a metadata field no existing envelope header carries, stop
and read `crates/tapes-capture/src/envelope.rs` first. The `X-Tapes-*` envelope is a cross-language
contract: this crate produces it in Rust and the Go parsers in tapes read it
back, and both halves table-test against one shared fixture corpus vendored at
`crates/tapes-capture/vendor/tapes-envelope-fixtures/`.

Adding a harness id is routine. Changing producer behaviour is not: update the
fixture corpus in the tapes repository first, then re-vendor with
`scripts/sync-envelope-fixtures.sh`. The oracle in `crates/tapes-capture/src/envelope_fixtures.rs`
must stay green against the vendored corpus — if a change makes it fail, that
conversation happens in tapes, not by editing the fixtures here.

Most harness-specific metadata does not need a new header at all. It belongs in
the base64url metadata blob, which is where Codex puts its originator, source,
and rollout path.

## What each field buys you

Step 1 said the declaration is load-bearing rather than documentation. This is
the ledger behind that claim: what each field causes, and where the code that
acts on it lives. Read it as a checklist against your own entry — a field whose
behaviour you do not want is a field to leave at its inert variant.

One caveat first. A field only reaches a consumer that consults it, and not
every consumer does yet. Where a consumer still hardcodes what the registry now
describes, this section says so rather than describing the intent as though it
were the behaviour.

### `id`

The canonical name, and the most load-bearing string in the crate:

- It is the `X-Tapes-Harness-Id` value, so it must already exist as a
  `HARNESS_ID_*` const in `crates/tapes-capture/src/envelope.rs` — `HARNESS_ID_CLAUDE`,
  `HARNESS_ID_CODEX`, `HARNESS_ID_OPENCODE`, `HARNESS_ID_PI` today — rather
  than being spelled inline here. `registry_ids_are_the_envelope_ids` pins the
  two together.
- It is what `supported_agents()` returns, and so the name a consumer offers.
- It is what `LaunchRecipe::harness()` must return; each recipe pins that in
  its own test (`crates/tapes-harnesses/src/launch/claude.rs`, `crates/tapes-harnesses/src/launch/codex.rs`,
  `crates/tapes-harnesses/src/launch/opencode.rs`).
- It is the key the tapes deriver reads on the far side of the wire, which is
  why renaming one is a two-repository change and not a rename.

It must also differ from `HARNESS_ID_UNKNOWN`. `"unknown"` is the miss sentinel
`Attributed::UnknownHarness` stamps, so a harness answering to it would be
indistinguishable from a failed attribution; the registry test loops the whole
set asserting that.

### `aliases`

The extra spellings `find()` accepts, through `matches_name()`, which trims and
compares case-insensitively so a consumer can pass a CLI argument straight in.
Two consequences before you add one:

- Aliases are **accepted but never advertised**. `supported_agents()` maps over
  `id()` alone, so `claude-code` resolves while `claude` is the name consumers
  print. The asymmetry is deliberate — one name to display, several to forgive.
- Every id and alias must be unique across the registry, since `find()` returns
  the first match and a duplicate would make resolution depend on declaration
  order. `every_name_in_the_registry_is_unique` enforces it.

An empty list is the common case: `CODEX`, `OPENCODE`, and `PI` all carry none.

### `user_agent`

The routing rule `for_user_agent()` applies, and through it the gate on the
Claude attribution lane. The pipeline does not read this field: it asks an
injected `UserAgentHarness` resolver which harness a `User-Agent` names, and
`RegistryUserAgents` — the implementation a consumer normally passes — answers
from `for_user_agent()`. So declaring the rule here is the whole edit; there is
no second prefix test in the pipeline to keep in step.

`UserAgentMatch::Prefix` is a **prefix, not a substring**: `some-claude-like`
must not be claimed by `claude`. Because `for_user_agent()` returns the first
entry that matches, two prefixes where one is a prefix of the other would
resolve by declaration order; `user_agent_rules_are_pairwise_disjoint` pins
that they never nest.

`UserAgentMatch::None` is not a gap. It says the harness is identified by other
evidence — a route the consumer recognises (`RequestFacts::codex_route`), a
launch marker, or the harness's own envelope. Codex declares `None` precisely
because its SDK's User-Agent is not harness-specific, so a prefix would either
miss real traffic or claim someone else's. If you cannot name a prefix that is
true of your harness's traffic and false of everyone else's, declare `None` and
identify it another way.

### `launch`

Read through `is_launchable()`, which is exactly the predicate
`supported_agents()` filters on. The variants differ in *who plans* the launch,
not in whether one happens:

- **`Recipe`** promises a `LaunchRecipe` in `crates/tapes-harnesses/src/launch/` whose `harness()`
  returns your id. The consumer constructs it — the registry deliberately holds
  no recipe instances, because recipes carry per-harness inputs (Claude needs an
  endpoint; Codex an endpoint plus an auth mode and a provider identity;
  opencode an endpoint per provider plus a model) and a registry that built them
  would need the union of every harness's configuration.
- **`ConsumerOwned`** means launchable, but the harness has no base-URL knob for
  a recipe to set, so capture depends on an installed extension plus whatever
  argv loads it — and that argv is not shared yet. `PI` is the only one.
- **`Unsupported`** keeps the harness out of every consumer's launchable list
  while leaving the rest of the entry — id, User-Agent rule, attribution
  strategy — fully in force. A harness you can capture but not start is a
  legitimate entry.

### `attribution`

The declarative statement of which shape the harness's identity recovery has,
and so which submodule under `crates/tapes-harnesses/src/attribution/` owns it. Step 3 covers what
each variant means; two behaviours hang off the *value* rather than off which
modules happen to exist:

- `SelfAttributing` is why `Attributed::stamp` preserves a complete inbound
  envelope instead of overwriting it with `harness_id: unknown`.
  `pi_is_the_self_attributing_variant` pins that exactly one harness is in that
  state, so a second one is a deliberate decision rather than a silent
  generalisation of that branch.
- `None` means traffic is still captured, under `harness_id: unknown`. It costs
  the session its identity, not its turns.

This field also reaches furthest downstream of any of them. paper's
`SUPPORTED_AGENTS` is defined as the registry's *attribution-capable* subset —
see "What a registry change sets off" — so this is the field that decides
whether your harness appears in `paper start` at all.

### `transcripts`

Where the harness's on-disk transcripts live, resolved by `transcript_root()`.
This matters more than its size suggests: wire capture yields a complete call
inventory but **no causal or fork skeleton**, and that skeleton exists only in
these files. A harness with `TranscriptSource::None` produces sessions whose
calls are all present and whose subagent structure is entirely absent.

`ClaudeProjects` resolves to `~/.claude/projects`; `CodexRollouts` delegates to
`codex::session::default_sessions_dir()` so `$CODEX_HOME` is honoured in one
place rather than re-implemented per caller. Honour whatever home-directory
override your harness itself honours, the same way.

`declared_transcript_trees_resolve_to_a_path` pins the pairing in both
directions: declaring a tree the crate cannot locate fails, and so does
`TranscriptSource::None` resolving to something.

### `plugin`

Whether capture needs a file installed **into** the harness.
`plugin_artifacts()` flattens the variants to a slice, and that slice is the
whole input to a `plugin install`: resolve the typed name through `find()`,
take the slice, write each artifact beneath the user's home.
`HookManifestTemplates` deliberately flattens to the empty slice — templates
carry un-rendered slots, so a file-copy installer must see nothing to copy;
an installer for that shape renders through `crates/tapes-harnesses/src/plugin/codex_app.rs` and
packages the result with `crates/tapes-harnesses/src/plugin/codex_app/manager.rs`, which also drives
the harness's own plugin manager over the packaged tree.

The empty slice is the ordinary case and is not an error. An installer must be
able to tell "nothing to do" from "no such harness" — `tapesctl plugin install
claude` says the harness needs no plugin, writes nothing at all, and exits zero
(`crates/tapesctl/src/plugin.rs` in the tapesctl repository).

## Partial entries and full ones

The registry holds five entries and not all of them are complete:

| field | `CLAUDE` | `CODEX` | `CODEX_APP` | `OPENCODE` | `PI` |
| --- | --- | --- | --- | --- | --- |
| `user_agent` | `Prefix("claude")` | `None` | `None` | `None` | `None` |
| `launch` | `Recipe` | `Recipe` | `Unsupported` | `Recipe` | `ConsumerOwned` |
| `attribution` | `SessionsDir` | `OpenRollout` | `LifecycleHooks` | `SelfAttributing` | `SelfAttributing` |
| `transcripts` | `ClaudeProjects` | `CodexRollouts` | `CodexRollouts` | `None` | `None` |
| `plugin` | `None` | `None` | `HookManifestTemplates` | `BundledExtension` | `BundledExtension` |

`OPENCODE` spent a long stretch as the honest partial entry — a recipe with
`AttributionStrategy::None` — and that state is still the shape most new
harnesses should start in, so it is worth being precise about what it does and
does not buy, because "it's in the registry" is easy to over-read.

**What a partial entry gets you.** `find()` resolves the name, so a consumer
answers "this harness needs no plugin" instead of "unknown harness".
`supported_agents()` includes it, so anything deriving its list from the
registry offers it without being edited. And with `LaunchSupport::Recipe` there
is a real, tested recipe in the crate — `crates/tapes-harnesses/src/launch/opencode.rs` plans the
config document that points opencode at the proxy — so the *crate* can plan the
launch that makes its traffic capturable.

**What it does not get you.** No attribution lane, which is the whole of the
difference. Partial-entry traffic matches no User-Agent rule and arrives on no
Codex route, so the pipeline returns `Attributed::UnknownHarness` and the
envelope is stamped `harness_id: unknown`. The turns land; the session is
anonymous. And with `TranscriptSource::None` there is no fork skeleton to
upload, so even a session you later identify by hand has a complete call
inventory and no subagent structure.

That is exactly the gap opencode's bundled plugin closed. The recipe could
always redirect, but a config file cannot name the session it belongs to;
`crates/tapes-harnesses/assets/opencode/tapes-gateway.ts` stamps the complete envelope (and the
capture-nonce echo) from inside the harness, which is what moved `OPENCODE`
from `None` to `SelfAttributing` — the pi road, taken by a harness that also
kept its recipe.

Take the partial state as a realistic bar rather than a discouragement: a
declaration plus a recipe is a genuine, landable contribution, and landing it
is what makes the attribution work reviewable in isolation afterwards. But if
your goal is `<consumer> start <your-harness>` working end to end, the registry
entry is necessary and not sufficient — the remaining work is the attribution
capability here, and then a pull request against the consumer (paper's
`SUPPORTED_AGENTS` filters to the attribution-capable subset, so a partial
entry is invisible to `paper start` by construction, not by oversight).

## Proving a new attribution lane

Attribution is the part of this crate you cannot review your way to confidence
in. Both bugs below shipped with unit tests that passed, and neither was caught
by them: the tests were right about the logic and wrong about the world. If you
are writing a lane, budget for the evidence below rather than treating it as
polish.

### Disambiguate by the harness's own thread identifier, never by recency

A harness running subagents is frequently **one process** holding the parent's
transcript and every child's open simultaneously. Neither the PID nor a launch
marker identifies a *thread*, so every candidate looks equally live and a
recency tie-break attaches a turn to whichever thread flushed last. This is the
worst class of attribution bug, because the result is a well-formed session with
a silently wrong shape.

- Find the harness's native per-thread identifier and confirm it is per-thread,
  not per-process. Codex stamps it on every inference call, and
  `CODEX_ROLLOUT_ID_HEADERS` reads `thread-id` **before** `session-id` on
  purpose: `session-id` stays pinned to the root session, so reading it first
  would attribute every subagent turn to the parent — the bug rather than the
  fix.
- Narrow candidates by that identifier *before* any tie-break, on **every**
  path — including timeout and discovery fallbacks. Those were fixed separately
  from the main lanes, because a discovery timeout is exactly the moment the
  named thread is the file the watcher has not surfaced yet, so the fallback is
  where a child's turn most easily lands on the lone visible parent.
- Treat "the request named a thread that none of the candidates are" as a
  **refusal**. The request is authoritative about its own identity, so "none of
  these" is information, not a licence to tie-break over the rest
  (`narrow_by_rollout_id`).
- Treat an **empty** candidate set as a plain miss instead. The file may still
  be appearing on disk, and the bounded poll is what waits for it.
- Treat "exactly one candidate matched" as evidence only when it matched *on the
  identifier*. A single most-recent candidate is a guess wearing an exact
  match's clothes, and it is indistinguishable from real evidence in a log line.
- Absent identifier means **no evidence**, never "matches nothing" — a blank
  value reaching a matcher refuses every candidate.

### Verify path encoding against the harness's real on-disk layout

Claude encodes a project directory by mapping **both** `/` and `.` to `-`:
cwd `/Users/x/.claude/jobs/…` lands at `-Users-x--claude-jobs-…`, with a double
dash where `/.` was. `encode_cwd` mapped only the slash, so every cwd containing
a dot resolved to a directory that does not exist.

The failure was **silent**. `session_files()` on a missing directory returns an
empty set (`crates/tapes-harnesses/src/transcript/files.rs`), so the affected sessions lost every
transcript turn *and* their fork-parent evidence with no error raised anywhere.
It was found by the first smoke run that happened to start from a dotted cwd.

- Derive the encoding from **real directories on disk**, not from the harness's
  documentation or your reading of its source. List the transcript root and
  compare it against the cwds you actually ran from.
- Test a dotted cwd explicitly, and pin it byte-for-byte — the double-dash case
  is pinned that way in `crates/tapes-harnesses/src/attribution/claude/fork_parent.rs` so a future
  encoding change is an obvious diff. Test whatever else your harness's users
  really have in paths: spaces, `@`, non-ASCII.
- Make a resolved-but-missing transcript root **warn loudly**. Today an empty
  upload set means both "this session wrote nothing" and "we computed the wrong
  path", and that ambiguity is what let a one-character bug survive. If your
  lane resolves a path it expects to exist, say so when it does not.
- Do not try to decode the encoding. It is not reversible — with dots mapped
  too, `/opt/my-project` and `/opt/my.project` now encode identically. `sweep`
  reads the true cwd out of the transcript's own records instead, which is the
  pattern to copy.

### Prove it end to end, from two different cwds

Unit tests cannot reach either bug above. The acceptance evidence is a real
session:

- Run a **real session through the real harness** — not a fixture, not a
  replayed cassette — and make it spawn subagents.
- Verify the captured session actually shows **transcript turns**. Zero turns
  with no error is the encoding failure, and it looks like a quiet success.
- Verify the **thread structure is correctly attributed**: each subagent's turns
  on that subagent's thread, the parent's on the parent's. A family collapsed
  onto one thread is the disambiguation failure, and it renders as a perfectly
  valid session.
- Do both **from a plain cwd and from a dotted cwd**. Only the second exercises
  the encoding path, and a lane proved from one is proved for half its users.
- Read the logs for refusals. A lane that never refuses anything is more
  suspicious than one that sometimes does — under the refuse-rather-than-guess
  rule, silence usually means the evidence was never consulted.

## What a registry change sets off

Adding or modifying an entry is a small diff with a wide blast radius. This is
the enumeration, so you can update deliberately rather than chase failures.

**In this crate**, the invariant tests at the bottom of `crates/tapes-harnesses/src/harness.rs` run
against the whole registry, so `cargo test` is the checklist:

- `supported_agents_is_the_launchable_subset_in_registry_order` asserts the full
  expected list literally. **Any** launchable addition fails it — that is the
  point, so update the expectation as a decision.
- `registry_ids_are_the_envelope_ids` asserts each id against its
  `crates/tapes-capture/src/envelope.rs` const by name; add a line for yours. Its loop over the miss
  sentinel covers new entries automatically.
- `every_name_in_the_registry_is_unique` fails on a duplicated id or alias.
- `user_agent_rules_are_pairwise_disjoint` fails if your prefix nests with an
  existing one.
- `harnesses_without_a_user_agent_rule_claim_nothing` and
  `declared_transcript_trees_resolve_to_a_path` both loop the registry, so a new
  entry is covered without being named.
- `pi_is_the_self_attributing_variant` fails if a second `SelfAttributing`
  harness appears — deliberately, since the envelope-preserving branch needs
  revisiting rather than silently generalising.
- `names_resolve_case_insensitively_through_aliases` asserts `find("gemini")` is
  `None`. Step 1's example is not idle: `gemini` is currently pinned as *absent*
  here, and separately in tapesctl's plugin tests, so that particular name
  breaks two test suites the day it becomes real.
- Outside the registry module: a new `HARNESS_ID_*` const in `crates/tapes-capture/src/envelope.rs`,
  and a `harness()` test in your `crates/tapes-harnesses/src/launch/` recipe if you add one.

**Downstream, but only when a consumer bumps its pin.** Both consumers depend on
this crate by git revision, so nothing breaks in paper or tapesctl the moment
you land here — it breaks for whoever bumps the pin next, which is why the
failures below are worth naming in your pull request description.

In paper (`platform/paper`):

- `crates/paper/src/cli/start.rs` — the test
  `supported_agents_match_registry_attribution_set` asserts that
  `SUPPORTED_AGENTS` equals the registry's attribution-capable ids
  **in registry order**. So a harness with any strategy other than
  `AttributionStrategy::None` breaks it, and a partial entry does not. Fixing it
  means updating the const *and* the `AgentKind` enum, `agent_kind()`, and the
  launch dispatch that the const gates — all in the same file.
- Prose lists in the same file (the "agent is required" message) and the
  shell-init tests in `crates/paper/src/cli/shell.rs` restate the agent names by
  hand and will not fail loudly.
- `crates/paper-daemon/src/proxy/session_recording/real.rs` hand-rolls the
  `claude` User-Agent prefix as a literal rather than calling
  `for_user_agent()`, so a change to a `user_agent` rule here does not reach it.

In tapesctl (`telemetry/tapesctl`), everything registry-derived lives in
`crates/tapesctl/src/plugin.rs`:

- `a_name_the_registry_does_not_know_is_refused_with_the_ones_it_does` resolves
  `gemini` and expects failure, and asserts the derived "known" list still
  contains `claude` and `pi`.
- `a_name_resolves_through_the_registrys_aliases_and_casing` pins the
  `claude-code` alias and the `pi` id.
- `installing_for_a_harness_with_no_plugin_succeeds_and_writes_nothing` pins
  `claude` at `PluginDelivery::None`; giving it an artifact breaks the test.
- `crates/tapesctl/src/start/mod.rs` and `crates/tapesctl/src/error.rs` restate
  the launchable set by hand and will not fail when the registry grows.

## The other two places

This crate is one of three places that hold harness knowledge. Full support for
a new agent also needs:

1. **The tapes deriver** — turns captured wire traffic into the session model.
   It keys on the `harness_id` you declared here.
2. **The envelope spec and fixtures** — only if the wire contract itself
   changes.

So a new harness is normally two pull requests: this crate, then the deriver.
A declaration-only entry (step 1, plus a launch recipe) is useful on its own
and can land first.

Add one more for each consumer whose `start` you want your harness to appear
in, until those consumers derive their lists from the registry rather than
restating them — "Partial entries and full ones" says where that stands.

## Before you open a pull request

```bash
nix develop
make check   # build + fmt-check + clippy + test
```

The crate denies `unwrap`, `expect`, and `panic` via `[lints]`; return `Result`
and surface errors through the crate error types.

Pull request titles use one of the repository's contribution labels — `✨ feat:`,
`🔧 fix:`, `🧹 chore:`, `📚 docs:` — and reference the relevant issue.