Skip to content

Surface parity

Agent Factory ships three clients over one daemon: the TUI, the web UI, and the CLI. Since #960 the daemon is the single writer, and since #1592 it is the central orchestrator with all three clients as thin clients over one API. The promise that follows is that they are the same product.

In practice capabilities drift. One surface gains a verb, an option, or a button; the others silently fall behind; nobody notices until a user hits the missing thing. That is a bug class, not a one-off — so it has a detector.

Drift is bidirectional — do not assume any surface is complete

The obvious story is that the TUI is the mature surface and the web is playing catch-up. That story is wrong, and believing it will make you read this table incorrectly.

Capabilities land wherever the implementer happened to be standing. The first audit (#1937) found gaps pointing in every direction:

  • The TUI was behind the other two on create-time prompts: the web modal and af sessions create --prompt both sent one, and the TUI could not (#1936 — now closed; the naming form grew a shift+tab initial-prompt field). The Prompt field was plumbed end-to-end to the daemon (app/session_control.go:106) and its only construction site never populated it — the plumbing was finished and simply never fed.
  • The CLI was behind both UIs on limit-retry: the TUI had c and the web gained a Retry button in #1934, while the CLI had no exit from the same [limit] state. af sessions retry-limit <title> now sends the same daemon action, closing that gap without duplicating the recovery logic client-side.
  • Only the CLI could choose a backend per session (#1933) — the web gained a picker over the daemon's ListBackends catalog in #1968, and the TUI's naming form gained the same field on ctrl+r. Both read the daemon's catalog rather than a copy of the enum, for the reason the enum level below explains.

The sharpest way to hold this: on CreateSession, no surface is a superset of another. All three accept different subsets of the same eight-field request — the TUI now sends a backend but still cannot send title_base, which the web does.

Create option TUI Web CLI
Title, program yes yes yes
Initial prompt yes yes yes
Backend (docker/ssh/hook) yes partial yes
Force-remote (hook) yes partial yes
In-place (--here) no no yes

The web's two partial cells are not missing controls: the browser sends backend and can select hook, but no one has yet shown a working remote session created from it (#1968 watched provisioning succeed and the session then time out waiting for its program). Reachable is not the same as proven — see "Known blind spots".

So when this check fails, the question is never "does the web need to catch up?" It is "which surfaces should have this, and which deliberately should not?" — asked in all three directions.

The two pieces

parity/inventory.json — every user-facing capability and which surfaces expose it, with a code pointer per cell and a verdict per row.

parity/parity_test.go — derives the real surfaces from code and fails when they disagree with the inventory. Run it with go test ./parity/.

The check is deliberately code-derived on all four halves:

Surface Derived from
CLI commands.NewRootCommand() — the real cobra tree, walked for verbs and flags after initCobraDefaults finishes building it (see below)
API daemon.HTTPRoutes() — the same table that builds the live mux, with request fields reflected off the wire structs
TUI keys.EffectiveBindings(nil) — the canonical binding table
Web the af<T>(method, body, token) call sites in web/src/api.tsaf() is POST-only, so it is the chokepoint for the SPA's POST RPCs, and a static read of those call sites is what the audit derives. A few non-POST control-plane calls hand-roll fetch instead (e.g. the config-assistant reap's DELETE /v1/config-assistant, since af() cannot express a DELETE) and are NOT derived here; those routes are also kept out of daemon.HTTPRoutes() (registered directly on the mux like the stream routes), so the audit stays consistent

A hand-maintained table would drift, which is the failure this exists to catch. So the only hand-maintained part is the verdict — the judgment a machine cannot make. Everything else is read out of the code at test time.

Four levels: verbs, options, enums, identifiers

A verb-level check alone is not enough, and that is not a theory — it is how #1948 got missed. af sessions preview existed, so "preview a session" looked like parity. But PreviewRequest carries Tab, TabID, and Full; the TUI sent all three and the CLI sent none, so the CLI could only ever see tab 0. The verb was present and the options were not. It was found by someone using the product, not by this check (and is now fixed — see the gap list below).

So the check works at four levels, each blind to the one below it:

Level Question Derived from
Verb can this surface do X at all? the cobra tree, the route catalog, the binding table, the web's RPC call sites
Option can it do X with the options the daemon accepts? CLI flags off the cobra tree; the wire structs by reflection, vs the AST of api/+app/ and the web's request bodies
Enum does it offer the same VALUES for those options? the canonical Go enum, vs what a surface actually lists
Identifier is the string we SHOW presentation-only, with the string we ACCEPT discoverable from it? the display rule (session.TabLabel) vs the resolver (session.TabMatches)

The option level is where the interesting gaps live, because they hide behind a verb that looks present. Two of the same shape so far — a field the daemon accepts that a surface never sends:

  • #1933 — the TUI never set CreateSessionRequest.Backend, so every TUI session ran on whatever the repo's checked-in backend key said (now closed; its fixture stays, repointed, and in_place is the field still unsent)
  • #1948 — the CLI never set PreviewRequest.Tab / TabID / Full (now closed; the fixture stays, repointed, in the honesty table below)

The enum level is the newest and the easiest to miss, because the other two both pass while it drifts. #1970 found it: the web hardcoded a copy of tmux.SupportedPrograms in two pickers, so the web did send program (field coverage called it covered) while adding a new agent server-side would have left the web silently unable to offer it with the whole suite green. A surface serving a stale copy of something the daemon owns is the #1933 shape one level down, so it gets the same answer — derive both sides and compare, rather than trusting a copy to stay in step. The structural fix is to SERVE the enum (the ListBackends pattern — POST /v1/ListPrograms), at which point the check is deleted and the row flips to parity, which is how

1970 was closed.

Every field a surface does not send must be declared in field_coverage as either {"gap": "<capability-id>"} (a tracked divergence) or {"ok": "<reason>"} (its absence is correct, and why). The field lists are derived, so a new field on any request forces a decision on every surface that builds it — nobody has to remember.

The identifier axis: what we show vs what we take

The nastiest of the four, because it is invisible until someone types it (#1984):

$ af sessions tab-delete alpha --name Terminal
session "alpha" has no tab named "Terminal"      # the TUI tab bar says "Terminal"
$ af sessions tab-delete alpha --name shell
# works

The TUI rendered a label and the CLI demanded a name, so the error asserted a tab was absent while the user could see it on screen — and left them to discover the mapping. One concept, two representations: the same disease as

1972 and #1970.

The rule lives beside the Tab type, not in the TUI, so "what a user reads" sits next to "what a user types" — and the two are deliberately allowed to differ (#1986):

  • session.TabLabel — the one definition of what a user SEES. Presentation only: agent and shell tabs render fixed labels (Agent/Terminal) that are not their names (agent/shell). ui/tree delegates to it, so display has a single source.
  • session.TabMatches — resolves on the canonical name alone. The label is never an identifier: accepting it would make two strings address one tab, the ambiguity #1929/#1904 removed from the tab surface.
  • session.TabIdentifiers — renders a tab as both spellings, so "no tab named X" lists the valid options with their labels. This is the whole mechanism now: the label never resolves, but a user who read Terminal off the bar is told the real name shell rather than left at a dead end.

#1937 first closed the gap by accepting the label as an alias; #1986 reversed that so the label carries no identity, keeping the #1984 symptom from returning through discoverability rather than a second handle.

TestLabelIsNeverAnAcceptedIdentifier enforces the invariant for every TabKind, including kinds with no UI yet: the name resolves, the label does not, and where the two differ the label is still named in the error.

The CLI-vs-CLI axis: argument shape

Parity is not only between surfaces. Within one noun group, does the same CONCEPT take the same SHAPE across sibling verbs? It is the same failure — a user who learned one verb cannot predict its sibling — and it was found the same way, by someone driving the CLI and getting stuck.

af sessions create --prompt X takes the prompt as a flag; af sessions send-prompt <title> <prompt> took it positionally and hard-errored with "unknown flag: --prompt" — naming the flag as wrong without mentioning that the positional is what it wants. Two siblings, one concept, two shapes.

Both halves are derived: flags from the cobra tree, positionals from each command's Use line. The check is a non-empty intersection of accepted forms, not identical sets — a verb that accepts both forms is compatible with either neighbour, which is why the fix is always additive: teach one verb the other's form and keep the old one. send-prompt now accepts --prompt as an alias and its positional still works, so nothing breaks and the shapes reconcile.

The one hand-maintained part is synonyms, and it is keyed per verb on purpose: --name on create means the session title, but --name on tab-create <title> --name <tabname> means the tab. A global name → title rule invents a divergence that is not there.

The same additive rule closed #1972: af sessions create <title> now shares the positional shape and vocabulary of its ten siblings, while --name <title> remains an alias for existing scripts.

What the check enforces

  • Every CLI verb, CLI flag, daemon route, TUI binding, and web RPC has an inventory entry. Adding one without an entry fails the build. Flags count because a flag is a capability — af sessions create existing says nothing about whether it can pass --backend.
  • Nothing is inventoried that no longer exists, so the table cannot advertise a capability af has lost.
  • Every field of every audited request is either reachable from a surface or declared, in both directions — a field a surface has quietly started sending also fails, so a fixed gap cannot keep being described as broken.
  • The table cannot contradict itself. A ledger mapping proves a surface reaches a capability, so the row cannot still say that surface is no; and every verdict is checked against its own cells (see Verdicts) — parity cannot outlive a missing surface, real-gap/unclear cannot outlive the gap closing, and a deliberate omission cannot outlive every surface making it. Otherwise "add the ledger entry" would be enough to make the check pass while the table went stale — which would make the inventory lie in exactly the way it exists to prevent.
  • No verdict passes by omission. A verdict with no rule is reported as unchecked and fails the run, so adding a fifth verdict value forces a decision about what it promises rather than inheriting a silent pass.
  • A declaration that silences a check must say why. An ok in field_coverage or argument_shapes carries the reason the absence is correct; a blank one is rejected, so silencing is never free.
  • Quality bar: a surface marked yes/partial must cite code, a deliberate verdict must explain itself, and a real-gap must name an issue.

Is the derivation itself honest?

This is the question that matters most, and it is not answered by the checks above. They compare the derivation to the inventory; none of them asks whether the derivation sees anything.

A derivation with a hole does not fail loudly. It silently under-reports and the suite goes green — which is worse than having no parity check, because the green gets trusted. A detector that manufactures confidence is the failure mode this package exists to prevent, so it must not be one.

parity/honesty_test.go therefore pins the derivation against verified field-level behavior — both shipped reaches and declared gaps — as fixtures, not aspirations:

Fixture Derivation path it proves
cobra's lazy surface — af completion bash, af help, --help, --version the tree is walked after cobra finishes building it
#1933 — the web now does send CreateSession.backend (#1968 landed), via a const body variable the web body parser, incl. variable resolution
#1933 (TUI half) — closed; sessionStartRequest now carries Backend, so the fixture is repointed to track that the walk still sees the TUI's use of backend/prompt/force_remote and its non-use of in_place the Go AST walk
#1948 — closed; the CLI now sets Preview.Tab/TabID/TabName/Full, so the fixture is repointed to track that the walk still sees both the CLI's usage and the TUI's non-use of tab_name the AST on an internal route, invisible to the public catalog
#1935TaskUpdate.project_path is now sent by every surface; the web still omits max_concurrent_runs nested recursion behind a wrapper route, plus the TS-interface read, web value walk, and the CLI's field-by-field assignment walk

Each fixture asserts in both directions: that the known gap is seen, and that a field the surface demonstrably does send is not reported as a gap. A parser returning nothing would satisfy the first half alone; it cannot satisfy both.

If a gap is ever fixed, its fixture fails and says so. That is correct — the fixture is retired deliberately, not silently.

The fixtures are verified by blinding each path and watching them fail: drop the nested recursion, remove the wrapper packages, break the TS parser, or break the call-body regex, and the matching fixture reports "the parser is blind" rather than passing.

The audit knows its own denominator

The question that comes before "do the surfaces agree?" is what did the audit actually look at?

An audit that under-covers does not merely miss gaps. It asserts parity over surfaces it never opened, and it is believed, because it is a green check — which turns "we have a gap" into "we have a gap and a test says we do not". Every hole found in this package so far had exactly that shape: a walk that ran before cobra finished building the tree, a web scan that skipped subdirectories, an enum check that read one of two selectors, a request the analyzer dropped instead of reporting.

So the audit states its denominator, and fails closed:

go test ./parity/ -v -run TestAuditCoverageReport
=== surface-parity audit coverage ===
  cli.arg-concepts                 89
  cli.commands                     70
  cli.flags                        182
  cli.noun-groups                  9
  cli.verbs                        62
  daemon.audited-request-types     29
  daemon.public-routes             31
  go.cli.files                     13
  go.cli.request-sites             32
  go.tui.files                     47
  go.tui.request-sites             23
  inventory.capabilities           73
  tui.bindings                     49
  web.hardcoded-enum-sites         0
  web.rpcs                         25
  web.source-files                 35
  verdicts:  deliberate=22  parity=35  real-gap=7  unclear=9  unchecked=0
  SKIPPED: none — every surface above was read

(A sample, not a contract: the counts move with the program. Only the floors are enforced.)

Three rules make that number honest:

  1. Anything not covered is a finding, not a pass. A file that will not parse, a construct the analyzer cannot read, a directory not entered — each is reported with its reason and fails the run.
  2. Unanalyzable is never a shrug. A request built in a shape the walk cannot read used to vanish from the derived set, taking its declarations with it. Now it is named: "cli builds PreviewRequest in a shape this analyzer cannot read … its field coverage is therefore UNVERIFIED."
  3. The denominator itself has floors. If cli.verbs or web.rpcs collapses, the run fails — a shrinking denominator makes every parity claim above it meaningless, and it is exactly what a silently-blinded derivation looks like.

The web body parser reads variables, not just literals

The web builds some request bodies as a variable — const body = {…}; body.x = y; af("Method", body, token) — which #1968 introduced for CreateSession's optional backend. A parser that only reads an inline literal after the method name goes BLIND on that shape and reports the body as empty, which is under-coverage: it would have said the web sends nothing for CreateSession and reported false parity over every create option.

So the parser resolves the body argument: an inline {…} literal, or a plain variable traced to its nearest preceding const|let|var … = {…} plus any body.field = … additions. Anything else at the body position — a function call, a spread — is reported UNANALYZABLE and fails the RPC's coverage, never dropped. This is finding (4)'s web analogue and it is LIVE, not latent: #1968's body.backend = … is exactly it.

Reach is derived from values, not types

For a wrapper payload the obvious move is to read its TypeScript interface. That is wrong in the dangerous direction: an interface says what is possible, and a client that never sends a field still passes.

TaskUpdate declares seven options; the single call site (web/src/index.ts:862) sends { enabled }. Reading the type credited the web with six options it cannot reach and reported parity over them. Reading the values it actually passes reports them as the gaps they are (#1935).

"Could not check" is a third answer

Worth stating because this package learned it twice, independently, and it generalises past parity.

The ListBackends contract (#1968) returns three outcomes, not two: available, unavailable + reason, and unknown + reason — where unknown means the daemon could not check (a repo config that would not parse), which is a different answer from yes and from no. Collapsing it into either one invents a fact. The same PR found the related trap: configured is not available — the hook backend was reported available without checking its commands were runnable.

This audit has exactly that shape and resolves it the same way. A request the analyzer cannot read is not "reached" and not "unreached" — it is unanalyzable, and it is a finding:

cli builds PreviewRequest in a shape this analyzer cannot read … its field coverage is therefore UNVERIFIED

Before that existed, an unreadable construct simply vanished from the derived set — unknown collapsing into fine, which is how a checker reports green over code it never understood. If you add a dimension here, give it three answers.

Known blind spots

Stated so nobody mistakes a passing check for total coverage. Note which way each one fails — over-reporting forces a decision, under-reporting hides one, and only the second is dangerous.

  • Reachable ≠ user-settable (under-reports — the dangerous direction). The AST proves a construction site sets a field, not that a user can choose its value. session.create.opt.prompt (#1936) was the canonical trap: app/session_control.go:106 sets Prompt, so the field read as covered while the TUI's naming flow never populated it upstream. The gap is closed (the naming form now has a shift+tab initial-prompt field), but the category remains — a field-level pass does not excuse reading the flow.
  • Internal routes are not in the verb-level route check (under-reports). daemon.HTTPRoutes() is the public catalog; Preview and the two *StatusPoll routes live in internalHTTPRoutes. They are covered at the option level via auditedRequests — which is what catches #1948 — but a new internal route that no surface calls would not trip the route check. ResumeFromLimit used to be in that list and is the cautionary case: it sat there for so long that the web shipped the usage-limit state with no way out of it, and no check failed, because an unreachable verb breaks nothing. It was promoted in #1934.
  • A nested payload built by a shared helper (over-reports — safe). The TUI patches a task via task.DiffTask, in a package every surface shares, so the walk cannot attribute it. Those fields are reported unreached and must be declared ok with evidence: a decision is forced rather than skipped.
  • Composite literals and simple var-assignment only. The walk reads T{Field: …} and var x T; x.Field = …. A request built some third way would read as setting nothing — over-reporting, and minGoLiterals trips if the surfaces move wholesale to another style.

Two things that must stay true

Both were once false, both reported green, and both are now fixtures:

Walk the tree only after cobra has finished building it. cobra adds completion, help, --help and --version lazily inside Execute(), so a walk of the freshly-constructed tree omits commands users can actually run. initCobraDefaults runs them first; TestDerivationSeesLazyCobraSurface fails if it is ever removed.

Declarations are validated from both ends. Walking only the derived requests catches a surface that is missing a declaration, but not a surface that drops a request another still uses — the CLI dropping PreviewRequest while the TUI keeps it would simply vanish from the derived set, leaving its declarations to rot while the suite stayed green. TestFieldCoverageDeclarationsAreLive walks the declarations the other way, so every declared (type, surface) must still be something that surface really does, and every declared field must still exist on the wire struct.

Verdicts

Each verdict is a claim about the row's own cells, so each one owes those cells a check. The checks live in verdictRules (parity/parity_test.go) — a table, not a switch, because TestAuditCoverageReport reads it too, to count the rows whose verdict nothing checks:

Verdict Meaning What must be true of the cells
parity every applicable surface exposes it no surface is no/partial
real-gap a surface should have it and does not — issue names the ticket some surface is no/partial
deliberate the surface legitimately cannot or should not; notes says why, so it is never re-reported as a gap some surface is no/partial, or some surface is n/a
unclear needs an owner decision; not filed some surface is no/partial

n/a as a status means the surface has no analogue by nature — af doctor diagnoses a broken install, including the case where the daemon is down and neither UI can run.

deliberate is the one whose rule needed care, and it is worth reading before changing it. It legitimately coexists with nothing missing, because its deliberateness usually lives in an n/a cell — af doctor has no UI analogue by nature. So it cannot join the real-gap/unclear rule ("something must be missing"); its rule is that the deliberateness must live somewhere. A row whose every cell is a plain yes has nothing deliberate left about it.

For how many rows that is, run TestAuditCoverageReport — this page does not restate the number. The first draft of this section did, in two places, and the two disagreed with each other and with the subset the sentence described, inside the PR that added the rule against unchecked claims. Derived tally or nothing.

That rule is late (#2609). Until it existed, deliberate fell through the switch and was the one verdict never compared to its cells — so a row could keep asserting an omission that every surface had since closed, with the suite green. It was found when two PRs collided in git over one row, which is not a detector: two PRs touching different rows would have left it standing. Hence unchecked=N in the coverage report — the number that surfaces this class without needing a collision.

What is deliberately NOT held to parity

Not every divergence is a bug, and recording that is half the value here.

  • Navigation and layout chrome. Each UI navigates its own layout idiomatically; the web has mouse drag-and-drop the TUI cannot express, the TUI has keyboard splits the web reaches via Alt-chords. A CLI has nothing to navigate. Key-for-key parity is a non-goal.
  • Scripting primitives. af sessions watch blocks until a session goes idle; both UIs show liveness continuously, so there is nothing to block on. Same for get, whoami, and --all broadcast.
  • Host lifecycle. Daemon install/restart, upgrade, reset, doctor, and token act on the host or the daemon itself. The web is served by the daemon — a button to stop it would kill the page — and the token is the credential the web needs before it can talk at all.
  • Internal plumbing. af gen-docs (hidden), af agent-server (daemon-consumed), af --daemon (hidden flag).

Each of these is recorded in the inventory with a deliberate verdict and a reason, so the next audit does not re-report it.

When the check fails

The failure names the item and the fix: add it to parity/inventory.json, map it in the ledger, and give its capability a status per surface with a code pointer and a verdict. If the other surfaces deliberately will not have it, say so in notes — that records the decision.

Do not silence the check. A new capability on one surface is precisely the moment to decide what the other two do about it, which is the whole point.

Updating the parser

If web/src/api.ts is restructured so its calls no longer match webCallRe, the check fails loudly via minWebCalls rather than quietly concluding the web calls nothing. Fix the parser in parity/derive_test.go; never lower the floor to make it pass.