# Verification — 2.0.0

What was checked before 2.0.0 shipped, what the check actually proves, and what
it does not. The last section is the point of the document: a rewrite that
replaces every screen invites the claim that everything was verified, and some
of it was not and could not be. Those items are listed as residual rather than
left out.

Recorded on Windows 11, Node 24.18.1, against the staged npm artifact built from
`c810305` — the 2.0.0 cut — plus the terminal gate, the ASCII completion and the
residual closure that follow it. Every gate below was re-run at the end of that
closure, not carried forward from when it was first recorded.

## Suites

| Gate | Result |
| --- | --- |
| `npm run check` — biome (1,065 files), `tsgo --noEmit`, installer render, browser smoke, engine sync | clean |
| `npm test` — default suite | 4,835 passed · 112 skipped · 348 files |
| `npm run test:process` — the out-of-process lane | 44 passed · 12 skipped · 7 files |
| `npm run test:kernel` — the Python kernel lane | 30 passed · 10 skipped |
| `packages/tui` suite | 737 passed |
| `PARITY_FINAL=1 PARITY_TOTAL=4835 npx vitest --run test/parity-manifest.test.ts` | passes both final gates |

The parity manifest holds 94 rows — one per old-UI test file — as 78 ported, 6
retired, 10 retargeted, 0 planned. Its floor is computed from
`test/parity-baseline.json`, frozen at M6a: 4,746. The delivered total clears it
by 89. The floor exists because deleting 24,922 lines of tests while the
assertion count quietly drops is the specific failure this whole lane could have
produced, and a total that only goes up is the only cheap proof it did not.

## The flakes

Two were recorded at 2.0.0 as observed rather than understood: each had passed
on an idle machine and failed on a busy one, which is the shape of a race and
not of a bug in the thing under test. Both are fixed in the mechanism, and a
third was found while proving it.

- **`ui-sims.test.ts` S3** — *"says it is reconnecting, keeps trying, and says
  when it is back"* — had two faults, and the first was in the harness. The
  daemon was spawned with `stdio: ["ignore", "pipe", "pipe"]` and **neither
  pipe was ever read**. An unread pipe is a fixed-size kernel buffer, and the
  supervisor writes to stderr on every diagnostic it produces — including a
  forwarded line for everything a session worker says to it. Fill the buffer
  and `console.error` blocks the supervisor's event loop: the daemon stops
  answering while still holding its socket open, which from outside is
  indistinguishable from a hang. It only fills when something is generating
  enough extra output to reach 64KB, which is the profile of a busy machine.
  The pipes are drained now.
  Draining is what made the second fix available. The daemon announces itself
  on stderr, so `awaitDaemonServing` waits for that line and then polls until a
  real `list` request **comes back with an answer** — before any clock the test
  starts. The previous wait was connect-plus-handshake, which a supervisor
  satisfies while it still has catalogs to build, and every second of that
  landed inside the budget for the reconnect banner. **The file went from 384s
  to 38s.**
- **`ui-parity-startup-drive.test.ts`** read rendered transcript text
  immediately after a `vi.waitFor` on `harness.sent`. Delivery and the notice
  settle independently — the notice is painted a render after the send that
  caused it — so the signal waited on and the signal asserted were different
  objects. Each assertion now waits on the text it reads. The same shape
  appeared once more in the image-stash case, waiting on the stash store and
  asserting the transcript; fixed too.
- **`daemon-supervisor-process.test.ts`**, found by the proof rather than by
  luck: *"archives resident roots and cancels their heartbeats on explicit
  daemon shutdown"* asserted zero worker descriptors on disk immediately after
  waiting for the socket to close and the worker to exit. The descriptor is
  removed by the supervisor on its way down, which is a third write — so
  proving the other two says nothing about it. The file already had
  `waitForCondition(() => countWorkerDescriptors(...) === 0)` in three places;
  three more sampled it instead. All now go through one named helper.

**The proof.** Five consecutive `npm run test:process` runs with the default
suite looping in a second shell, then five consecutive default-suite runs with
the process suite looping in a second shell.

| Claim | Result |
| --- | --- |
| default suite, 5 consecutive runs under process-suite load | **5 / 5 green**, 4,835 passed each time |
| S3, across 5 process-suite runs under default-suite load | **5 / 5 green** |
| the descriptor test, same 5 runs | **5 / 5 green** — it was red on the run that found it |
| the process suite as a whole, those same 5 runs | **3 / 5 green** — see below |

**The two red runs are named, because a proof that only reports its successes
is not one.** Neither was S3 and neither was the same test twice: run 2 failed
`4606-update-restart-coordinator` (*"keeps a relative agent directory stable
across coordinator cwd changes"* — its spawned coordinator exited 1) and run 4
failed `4685-daemon-client-modes` (*"loads headless runtime services only in
the worker"* — the daemon it spawned "wrote nothing to its log" inside the
window). Both spawn a real `tsx`-loaded Node child and wait a fixed time for it
to come up, and both were competing with a fully parallel default suite for
every core on the machine. **They are not fixed here, and deliberately not by
widening their windows** — a wider window is the thing this exercise exists to
avoid, and unlike S3 there is no wrong signal to correct: the signal is right
and the process genuinely had not started. What they are is a real sensitivity
to CPU starvation beyond anything CI applies, recorded so the next person sees
it named rather than rediscovers it.

Across the whole exercise the default suite ran 29 times — 5 as the proof, 24
as load — and was green 28 times; the single red one was
`agent-session-autonomous`, under the heaviest contention of the run.

## Non-suite gates

| Gate | Result |
| --- | --- |
| `npm run clean && npm run build` + copy-assets | `dist/modes/` contains no `interactive/`; `dist/theme/{dark,light,zyte,theme-schema}.json` present |
| `stage()` + `verify()` from `scripts/publish-npm.mjs`, replicated into a temp directory | every tripwire passes; zero source maps; no engine tests; tarball 2.8 MB compressed, 14.3 MB unpacked, **162 files** |
| Static gates (`test/static-gates.test.ts`, 12 cases) | clean across `src/`, `test/`, `docs/`, `scripts/`, `examples/`, `package.json` |

The staging gate is replicated rather than invoked: `publish-npm.mjs` builds and
packs at module scope, so importing it runs a publish path. The required-file
list is read out of that script's own text instead of restated, so a path that
goes stale there fails here rather than after tagging. Nothing publishes; the
OTP step stays with a person.

`node-pty` is a devDependency of the **private workspace root**, not of the
published package. The staged manifest is generated field by field by `stage()`
and lists exactly `zeromq`, `undici` and `@silvia-odwyer/photon-node`; adding
the gate's dependency did not change it, and the tripwire that asserts that
dependency list is one of the ones above.

**The staged tree did not move.** 162 files, the same 162 the 2.0.0 record
listed, and every staged path is identical once the bundler's content hashes are
normalised — the seven `dist/bundle/*-XXXXXXXX.js` names that changed are chunks
whose contents changed, which is what a content hash is for. 51 bundle entries
before and after. Zero source maps, no engine tests, the same three
dependencies. `node-pty` appears in no staged path, not in the staged manifest,
and not in the bundle; `packages/coding-agent/package.json` is **byte-identical
to `c810305`**, so the published manifest is still the one 2.0.0 was cut with.
The bundle's contents differ, as they should: closing the ASCII residue is a
product change, not a packaging one.

## The terminal gate

`node scripts/terminal-gate.mjs` builds, stages the npm artifact into a temp
directory, and drives the real `dist/bundle/cli.js` through a genuine Windows
ConPTY at 80×24. It is a release gate, not a per-commit one.

Three things are structurally invisible to the emulator suites, and each is why
this exists: `setProgress` and `setTitle` write straight to `process.stdout`, so
no in-process capture ever sees OSC 9;4 or OSC 0; a resize arrives from the
operating system rather than from a method call, so what the app writes back
cannot be asked in-process; and `drainInput` exists so a late key-release cannot
leak into the shell that gets the terminal back, and there is no shell in a unit
test.

| Assertion | Result |
| --- | --- |
| node-pty loads (ConPTY bindings) | pass |
| staged tree passes publish's own tripwires | pass — 9 required files |
| staged CLI entry point exists | pass — `dist/bundle/cli.js` |
| startup surface arrives | pass — splash metadata and composer hint on screen |
| OSC 0 sets the window title | pass — 2 writes, last `zyte-agent - project` |
| composer echoes typed text | pass — 23 characters typed and read back |
| resize 80×24 → 40×12 within budget | pass — 664 bytes against 6,976 |
| resize 40×12 → 200×60 within budget | pass — 4,836 bytes against 76,096 |
| resize 200×60 → 80×24 within budget | pass — 1,418 bytes against 15,616 |
| each resize keeps the scrollback | pass — zero `ESC[3J` in every resize window |
| no scrollback erase anywhere in the session | pass — zero `ESC[3J` in 11,433 bytes |
| synchronized-update markers balanced | pass — 23 DECSET 2026 begins, 23 ends |
| OSC 9;4 absent with the setting at its default | pass — zero progress writes |
| OSC 9;4 present with the setting on | pass — 1 progress write |
| double ctrl+c exits the process | pass — exit code 0 |
| no stray control bytes after the terminal is restored | pass — zero bytes follow the final restore sequence |
| an ephemeral session prints no resume hint | pass |
| the real `~/.zyte` was never touched | pass — asserted on directory mtime |
| WSL + tmux pass | **not run** |

The resize budget is `4096 + cells × 6` bytes, stated rather than discovered: a
resize legitimately repaints the whole viewport, so the ceiling has to scale
with geometry, and what it must not scale with is the session. The measured
values sit between 6× and 15× under it, and they are printed on every run so the
headroom stays visible instead of being asserted away.

**Re-run after the residual closure, against a fresh `clean && build`: 21
passed, 0 failed, 1 skipped.** Every measured value above is the value it was at
2.0.0 — the same 664 / 4,836 / 1,418 bytes, the same 23 begins against 23 ends,
the same two title writes — which is the point of routing glyphs through the
mark tables rather than editing them in place: with the terminal drawing
unicode, nothing about the bytes changed at all.

The resize storm ran with a draft in the composer, because a resize on an empty
screen is the one case that cannot lose anything.

**Two directions, deliberately.** OSC 9;4 is asserted absent at the shipped
default *and* present with the setting on. Absence alone would be satisfied by a
site that never fires; the presence case is observable here because `stop()`
clears the indicator on the way out whenever the setting is on, which needs no
model. The resume hint is the mirror image: `--no-session` has no file to resume,
so its absence is the correct answer and asserting it is what stops the
gate's silence from being mistaken for coverage. The hint's *presence* needs a
persisted session with a model's answer in it, so it stays with the unit test
that can construct one.

**The sandbox.** `HOME`, `USERPROFILE` and `ZYTE_AGENT_CODING_AGENT_DIR` are
redirected into a `mkdtemp` home, the daemon endpoint is unique to the run, no
provider is configured and no credential is ever held. Onboarding is skipped by
seeding `onboardingShown`, not by configuring an account. Teardown kills every
process whose command line names one of the run's own temp directories — which
is how the daemon supervisor and the version-check child get collected — and
then asserts the real `~/.zyte` directory mtime is unchanged, without opening
anything inside it.

**WSL and tmux: not run, and not claimed.** `wsl.exe -l -v` reports *"The Windows
Subsystem for Linux is not installed"* on the machine that cut 2.0.0, so the
reduced tmux pass — ctrl+z then `fg` redraw, a resized client redrawn at the new
size, `/quit` leaving no session — did not execute. The branch is written and
skips with that reason; on a machine with a distro and tmux it needs a Linux
build named by `ZYTE_GATE_WSL_CLI`, because the Windows artifact links native
binaries that cannot load under WSL and reusing it would produce a green row for
a process that never started. **Treat the tmux branch as unexecuted code.** What
the in-repo suites do cover of that ground: `ui-laws.test.ts` asserts the ctrl+z
SIGCONT redraw and the fullscreen enter/leave counts against the emulator, and
`drainInput(1000)` is asserted on the shutdown ordering — but neither has ever
run inside a real multiplexer.

## What is machine-verified, and what is not

Machine-verified, in the sense that a failure fails a command:

- Every screen's layout at 80×24 and 40×12, in four capability variants
  (`zyte`, `zyte-light`, `NO_COLOR`, `ZYTE_UI_ASCII`), as 22 pinned goldens plus
  the nine ASCII-completeness assertions tabulated below.
- The rendering laws on captured byte streams: no scrollback erase, balanced
  synchronized frames, byte budgets on eight flows, alt-screen entered and left
  the same number of times.
- The same laws on a live ConPTY stream, plus OSC 0, OSC 9;4 in both directions,
  the resize response and the post-exit silence.
- The 28-method extension contract, the public barrel by name and `typeof`, the
  keybindings round trip including `reload`, and custom 52-key themes end to end
  including hex-identical HTML export colours.
- Four sims across process boundaries: a fresh machine from a real `npm pack`,
  the agents-view alternation over a real socket, a daemon killed mid-session and
  restarted, and `/update` declining to relaunch.

Not verified — residual, and listed because it is residual. Everything on this
list that a machine could close has been closed; what is left is what a machine
on this hardware cannot reach, plus one entry that is a decision rather than a
gap. It is not the only thing in this document left open on purpose — the two
starvation-sensitive process tests above are named there rather than here,
because a test that is slow to trust is a different claim from a product
behaviour nobody checked.

- **GPU-rendered tearing.** Whether a frame reaches the glass intact is decided
  by the terminal's renderer, not by the bytes. The byte-level guarantee
  (synchronized-update markers, balanced and asserted) is what an application
  can offer; it is not the same claim. **Eyes only.**
- **Physical mouse selection and paste.** The copy-source registry is unit
  tested and `undecorate` is asserted against gutters, fold markers and ANSI —
  but nobody dragged across a tool block in Windows Terminal and pasted the
  result into an editor as part of this release. **Eyes only.**
- **Perceived latency.** Byte budgets bound the work; they say nothing about how
  the product feels while typing during a stream on a slow ssh link. **Eyes
  only.**
- **The tmux and ssh checklists**, per the section above. The branch is written
  and skips with its reason; the machine that cut 2.0.0 has no WSL, so it is
  **unexecuted code** and is recorded as such rather than as a passing row.
- **ASCII on `src/ui/compat/**` and on `@zyte-agent/tui`.** Not a gap — a
  **deliberate compatibility boundary**, argued in the ASCII section above.
  Both are published component surfaces whose rendering is what third-party
  code depends on, and changing how they draw is the thing they exist to
  prevent.
- **A real provider.** Every gate here runs against a stub or no provider at
  all. Nothing in this record says anything about a live model turn. Kept on
  this list rather than dropped from it: the point of the document is that
  unverified things are named, and this one is unverified because an agent
  cannot hold a credential, not because anyone chose not to check.

## Branding

The allowlist of upstream-name survivors is four paths; the gate asserts the
found set is a **subset** of it and that at least one entry is still live, so it
neither fails when a site disappears nor rots into a comment. Current state:

| Path | State |
| --- | --- |
| `src/core/model-registry.ts` | live — `originator: "pi"`, a value a provider reads |
| `src/core/extensions/builtin/herdr-agent-state.ts` | live — `"herdr:pi"` source key, plus the legacy `~/.pi/agent/` path |
| `src/core/providers/openai-codex-responses.ts` | gone — the file does not exist in this package |
| `src/core/oauth/openai-codex.ts` | gone — the file does not exist in this package |

So the plan's four survivors are two. Both are compatibility values on the wire,
not labels, and changing either is a compatibility decision rather than a
rename. Comment lines are stripped before the scan: prose survivors in doc
comments get swept but never block a release.
`SkillDiscoveryMode = "pi" | "agents"` and the update-source check are
behavioural identifiers and are out of scope by construction.

## ASCII

`ZYTE_UI_ASCII=1` is asserted, not described. Nine surfaces must contain **no
byte above 0x7f**, and the failure names the character and the line it is on:

| Surface | What it carries that nothing else does |
| --- | --- |
| screen 1 — the idle chat | the brand mark, the composer, the footer |
| screen 2 — a turn mid-stream | the loader spinner and its activity label |
| screen 10 — the agents view | row marks, the heartbeat badge, key hints |
| a transcript with tool rows and a footer, drawn wider than 80 | every field separator, at a width where the footer sheds none of them |
| the session tree | connector art — corners, stems, the fold column, the active-path mark |
| the `/context` overview | the same connectors plus a shaded meter bar |
| the settings screen | the cursor, the submenu chevron, the arrow-key footer |
| the configuration menu | the tab marker and the tab-bar separators |
| the onboarding splash | the wordmark and a procedural field of box glyphs |

The last five closed the residue the previous pass named and the residue it did
not. Named and closed: the tree selector's connector art; the context-tree bar;
the classic `SelectList` cursor in `src/theme/theme.ts`, which cannot import
`paint` — `src/theme/` is outside the UI tree precisely so the extension
contract, `core/`, the daemon and RPC can import a theme without importing a
UI — and so degrades off `src/theme/terminal-capability.ts`, the same
environment signal `paint` reads, extracted from `working-icon.ts` so the two
cannot drift. **The tie is the signal, not the code.**

Found while closing them, and closed in the same pass: the heartbeat manager's
separators and its absent-value dash, the scoped-model and user-message
cursors, the settings submenu chevron and its `←→` hint, the configuration
menu's tab marker, the reconnect banner's ellipsis, `truncatePathMiddle`'s
ellipsis, the compaction loader's, the whole `DEFAULT_KEYS` arrow table — and
the onboarding splash, which was still drawing the unicode wordmark
unconditionally. **That last one is the point of the table above.** The
previous pass fixed the chat header's wordmark and asserted the chat header;
the splash renders the same six rows of Block Elements and comes *before* the
chat, so on the terminals this switch exists for, the first thing a new user
saw was still a wall of boxes. An assertion that names its surfaces cannot
catch a surface nobody listed — which is why the list now runs to nine and why
every glyph either resolves through the mark tables or sits in a named local
table beside the illustration that uses it.

Two boundaries remain, deliberately, and both are the same decision:

- **`src/ui/compat/**`.** One rendered glyph, a `·` in
  `settings-selector.ts:188`. These are the published components the host
  itself never constructs — the whole tree is reachable only through
  `src/index.ts` — and they are frozen so third-party code that imports them
  keeps rendering what it rendered before. Changing how they draw would break
  the compatibility they exist to provide.
- **`@zyte-agent/tui`.** The rendering layer has no capability notion at all:
  `SelectList` draws its own `› ` cursor, `…` truncations and `↑ N more` scroll
  hints, and `Editor` draws `─` borders and its own scroll indicators. The host
  does not meet most of these — it supplies a background surface instead of the
  editor's border, and the four screens above are clean — but an open
  autocomplete popup reaches them. Routing them means giving a published
  rendering package a capability system and deciding what ASCII means for its
  markdown and LaTeX tables, which is a design decision about a separate
  package rather than a loose end in this one. Same reasoning as `ui/compat`,
  with more force: it is the more widely published surface of the two.
