# Changelog

All notable changes to yothere (formerly `relay-cockpit`). Format loosely follows
Keep a Changelog; versions follow semver.

## [Unreleased]

## [1.29.1] - 2026-07-17

### Fixed
- **Self-update no longer parks forever on pipx venvs without pip.** `selfupdate.run_upgrade()`
  ran `python -m pip install` for the pip-venv flavor, which fails rc=1 "No module named pip"
  on pipx app venvs that ship without pip (modern pipx no longer seeds a shared
  `pipx_shared.pth`). A machine with "Automatic updates" on then stamped a 6h backoff and
  never recovered, silently staying on the old version. `run_upgrade()` now probes
  `python -c "import pip"` and bootstraps pip with the stdlib's `ensurepip` before installing.
  Healthy venvs are byte-for-byte unchanged; uv-tool is never probed; a failed bootstrap
  parks the same backoff and keeps serving on the old code. (#281)

## [1.29.0] - 2026-07-17

### Added
- **OpenClaw is a third brain (`openclaw`), alongside `claude` and `codex`.** yothere runs
  turns through `openclaw agent --local` behind the same `TurnBackend` seam. Pinned and
  probed against openclaw 2026.7.1 (`OPENCLAW_TESTED_VERSIONS`); capabilities WARNs on any
  other version. Model routing is openclaw's own (`YOTHERE_OPENCLAW_MODEL`, default
  `deepseek/deepseek-chat`); cost is estimated from tokens (openclaw reports no dollars).
  See `docs/openclaw-backend.md`.
- **Containment for openclaw is config-borne (no deny hook), observe + workspace tiers
  only.** openclaw's exec policy is `deny | allowlist | full` with no allow-all-minus-denylist
  and no pre-exec veto hook, so the claude/codex "full shell + send-deny gate" is
  inexpressible. A policy asking for the full posture **degrades closed to workspace**;
  `security=full` is never emitted. Observe denies all shell; workspace runs a safe
  build/read allowlist (no `git push`, no outward senders). The outward-acting tool surface
  (`message`/`cron`/`nodes`/`gateway`/…) is denied at the tool layer.
- New knobs: `YOTHERE_OPENCLAW_BIN`, `YOTHERE_OPENCLAW_MODEL`, `YOTHERE_OPENCLAW_HOME`,
  `YOTHERE_OPENCLAW_PRICE_TABLE`, and `openclaw_model` in the thread config.
- **OpenClaw is discoverable, not just configurable.** A paired machine advertises its
  OpenClaw state (ready, or `warn:openclaw` for an untested CalVer version with the pin fix
  named); the Settings → Machines Brains line and the `/overview` cap strip render it; the
  Models card names it as a third per-machine brain (observe/workspace only); and onboarding
  validates an `openclaw` default brain and names OpenClaw as an alternative when installed.

### Notes
- The **voice path** and **task-source jobs** build their own `claude -p` argv and bypass
  the seam; they remain **claude-only** by design.

## [1.28.0] - 2026-07-17

### Added
- **Codex is discoverable from the cockpit, not just Claude.** The Machines card now
  shows a per-machine *Brains* line (Claude / Codex) read from that machine's advert;
  a Codex that is installed but not signed into yothere's own `CODEX_HOME` shows the
  one-line `codex login` fix inline. The account model card is retitled *Claude models*
  and names Codex as a separate per-machine brain with its own model
  (`YOTHERE_CODEX_MODEL`). Onboarding names Codex as the alternative when both agent CLIs
  are installed. A machine advertises `warn:codex` so the control plane can tell "not
  installed" apart from "one login away" and name the fix. *Ships as:* wheel (this
  release) for the advert + onboarding probe; the hosted cockpit's card + copy deploy
  with the control plane.
- **MCP editor delegation.** `spawn_thread` takes a `repo` param (a registered
  workspace name/alias; fail-closed — an unknown or ambiguous repo spawns nothing
  and returns the clarify with `repo_error`), and a new read-only `list_workspaces`
  tool lists the registered repos so a coding agent can match its working directory
  and hand a separable chunk of its task to the fleet. New site page:
  *Delegate from your editor* (walkthrough + a copy-paste CLAUDE.md/AGENTS.md
  snippet). *Ships as:* wheel (next PyPI release); the site page deploys with the
  website.

### Changed
- **Inbox FYI cards read as blocks, not a wall of text.** A finished lookup's answer
  ("the five best taco places", "your weekend at a glance") is authored as one
  paragraph of spoken prose so the voice path can read it aloud, which made the card
  a dense block. The cockpit now reflows that prose for the eye only (it never changes
  what the worker wrote or what voice speaks): an enumerated answer becomes a lede plus
  a bullet list, a multi-fact answer breaks into one line per fact, and already-formatted
  text is left untouched. The redundant bold title on a dispatched-result FYI (the answer
  re-truncated to 500 chars above its own body) is dropped, so the type-label's task name
  heads the card and the answer shows once. Applies to the Done-session result fold too.
  *Ships as:* wheel (next PyPI release); the hosted cockpit deploys with the control plane.
- **Cockpit type scale bumped for laptop legibility.** The `/overview` type tokens
  each move up ~1px (body 15→16, secondary/card-body 13→14, meta labels 11→12, micro
  pills 10→11, plus the two display sizes) so the reading floor sits at 14px instead
  of 11–13px. The whole page rescales from six `:root` variables, so hierarchy and the
  4px spacing rhythm are unchanged; no layout breaks (three lanes intact, cards wrap as
  before). *Ships as:* control plane (`fly deploy`) for the hosted cockpit; the local
  probe rides the wheel (next PyPI release).
- **Mute now cuts only your microphone, never yothere's voice.** Muting used to
  also hold the proactive nudges, so a muted call went fully silent: no finished
  results, no status, no fleet heads-ups until you unmuted. Now the mute is
  one-sided. Nothing you say reaches the model while muted (unchanged, still
  enforced server-side), but yothere keeps speaking when something lands, paced
  the same as when you simply are not talking. Saying "mute me" now tells you
  so: you will still hear results arrive. *Ships as:* wheel (next PyPI release).
- **Cockpit lanes now read newest-first.** The Inbox orders by newness (freshest
  ask on top, blocking asks still above fyi), and Working ranks session cards and
  running threads on one recency axis: the latest spawned card sits on top, and a
  thread that starts again (answered ask, resume, new turn) jumps back to the top
  of the lane. Past inherits the same newest-first read. Previously every lane was
  alphabetical by title, so a fresh spawn could land mid-list and a restart never
  moved. *Ships as:* control plane (`fly deploy`) for the hosted cockpit; the local
  probe's ordering rides the wheel (next PyPI release).
- **Complex tasks now plan first — on every dispatch surface.** The plan-vs-auto
  classifier learns four deterministic complexity signals, calibrated against a census
  of ~55 real dispatches (each rule flips its intended census rows and zero
  should-be-auto rows; anonymized exemplars pinned in `experiments/fixtures.jsonl`,
  now 28 gold rows at 100%): a *mutation batch* (two-plus distinct mutation verbs on a
  work target — "revise the open PRs, push the ready ones, drop the stale") plans; an
  *investigate-and-fix* with a named problem ("check the channel and fix the access
  issue") plans instead of riding the "check" quick-lead to auto; an explicit
  *then-chain* carrying a mutation ("upgrade every box and then verify the services")
  plans; and two new auto guards keep friction out — a *conversational confirmation*
  lead ("Yes, pull the image and then …") is a reply, never a fresh project, and
  "create a" on a *one-shot artifact* (calendar blocker, invite, reminder) no longer
  demands a plan review. `pr`/`pull request` join the code-target vocabulary and
  `upgrade` the change verbs.
  **Voice dispatches now classify too**: `spawn_voice_thread` hard-defaulted every
  spoken task to auto — the census caught "update yothere to the latest version"
  spoken running the whole fleet upgrade with zero plan review while the identical
  typed dispatch planned. It now runs the same deterministic per-utterance verdict as
  `/dispatch` (on the raw utterance, not the decorated task body).
  *Ships in two parts:* typed `/dispatch` classification runs on the control plane
  (next `fly deploy`); the voice/local paths ride the next release's wheel.

### Fixed
- **A rejected plan's card no longer haunts the Inbox.** status.json is a merge store,
  and the worker contract's reject move ("write state done + summary") never erases the
  ask keys — so rejecting a plan card left `ask{approve_plan}`/`kind`/`question`/
  `options`/`asked_at` attached to the done thread, and fleet_state's deliberate
  done+needs_eyes re-raise pinned the dead card in the Inbox with live
  Approve/Revise/Reject verbs, forever (observed live 2026-07-16, thread
  `yothere-newest-version`; same class as the 2026-07-11 Discard resurrection, which
  fixed the host reply path — the plan verdict has no host reply to hook because the
  WORKER writes the terminal state). Two halves:
  *Worker half* — `resume._clear_stale_ask`, a new first stamp in `_stamp_after_turn`:
  when a turn's terminal write moves off a blocking ask without authoring a new one
  (no CLEARED key changed), the host erases the residue at the source. Evidence is the
  same pre-turn byte snapshot `_worker_landed` uses; an fyi riding a running thread is
  never swept, and a worker's own fresh terminal ask survives. `ask.CLEARED` now also
  erases `asked_at`, so a dead ask's timestamp cannot leak onto later writes.
  *Read-side belt* — `ask.stale_done_ask` behind fleet_state's done re-raise: a
  blocking approval (`approve_plan`/`approve_send`/`review_diff`/`review_checks`) on a
  done thread is a state-machine contradiction and always residue; a `question`/
  `review_result` is residue only when its `asked_at` predates the terminal write by
  over 10 minutes (model-authored stamps fail open to fresh). Stale degrades to the
  existing dismiss-only result fyi — the outcome stays visible, the dead verbs go —
  and phantom pushes (cloud/notify counts needs_eyes) stop for free.
  *Ships in two parts:* the belt is live for every tenant with the next control-plane
  `fly deploy` (covers old wheels and every already-written status); the worker half
  rides the next release's wheel.
- **"Paired!" no longer claims a state the machine is not in.** The /activate approved
  page used to say the machine "is now connected... starts on your queued tasks within
  a few seconds", which is false for every first-time pairing: approval only mints the
  credential, and the machine is connected only once `yothere service` runs on it and
  polls /jobs/next. The page now says approval is one step from done, shows the exact
  command (`yothere service install`), and live-polls /device/list to flip to
  "Connected" the moment the machine first checks in (honest static copy with JS off).
  The same approved-but-never-seen state is now named everywhere instead of reading as
  broken: Settings -> Machines shows "approved, waiting for first check-in" plus the
  fix, `yothere devices` says what "never" means, and the cockpit's
  copy-the-ask-for-your-agent snippet now includes starting the service after approval,
  so an agent-driven pairing lands fully connected instead of paired-but-idle.
  *Ships as:* control plane (`fly deploy`) for the /activate page, Settings, and the
  cockpit ask; the `yothere devices` wording rides the wheel (next PyPI release).
  (Landed after the v1.27.0 tag was cut, so it is NOT in the 1.27.0 wheel; the hosted
  control plane already runs it.)

### Added
- **A repo picker at dispatch.** The cockpit's repos chip is now the anchor for
  pinning the NEXT dispatch to one registered workspace: click it, pick a repo, and
  a removable "next task runs in <repo> ×" pill scopes the dispatch. The pin is
  validated against the machines' own `ws:` adverts (an unknown name spawns NOTHING,
  the fail-closed rule every repo route follows), rides thread meta as the workspace
  NAME, and /jobs/next now HOLDS a workspace-bound thread for a machine that actually
  has that workspace — on a two-box fleet the pin routes to the right box instead of
  sticking on the wrong one's WorkspaceUnavailable card. Unpinned dispatch is
  byte-identical to before. *Ships as:* control plane (`fly deploy`); every shipped
  wheel already resolves the name at turn time. (#260)
- **Pick your models in /settings.** A Models card sets the account-default worker
  model and planning model (Sonnet/Opus); the choice reaches BYO machines on the
  /jobs/next envelope (`caps=model`, the language rollout's shape) — the years-old
  `worker_model` columns finally have a writer and a channel that reaches a laptop.
  A machine's own `YOTHERE_WORKER_MODEL` env stays the owner's hard override, and
  /settings names any machine whose wheel predates model selection instead of the
  setting silently doing nothing there. *Ships in two parts:* the card and envelope
  are live at the next control-plane deploy; honoring `job["models"]` rides the
  wheel (next PyPI release). (#262)
- **Machines can be renamed.** The /settings Machines label is an inline rename form
  now (display-only; the device credential is untouched), so the box that shows up as
  "unnamed machine" or a hostname can be named where you manage it. (#260)
- **A machine that caps itself locally is named (R10).** The wheel advertises its own
  tier cap (`cap:<tier>`, from the same function resolve()'s min-merge uses, so the
  advert can never disagree with enforcement), and /settings warns when a grant
  exceeds it — naming the machine and the real knob (`YOTHERE_WORKER_PERMISSION_MODE`)
  instead of the grant silently losing the merge. An old wheel advertises nothing and
  renders nothing: silence is not a claim, the min-merge stays the enforcement. Also
  bumps the `ws:` advert name cap 24 → 48 so real repo names survive to the picker.
  *Ships in two parts:* the warning is live at the next control-plane deploy; the
  `cap:` advert rides the wheel (next PyPI release). (#263)
- **Automatic updates, per machine (opt-in).** Settings -> Machines gains an
  "Automatic updates" checkbox: when on, that machine upgrades its own yothere from
  PyPI and restarts itself — only while idle, never mid-turn. Mechanics: the leaser
  advertises a new `selfup` cap (only when its install can actually self-upgrade:
  pipx/venv via its own interpreter's pip, `uv tool` via the uv binary; system
  pythons and ephemeral caches never advertise), and the idle /jobs/next response
  becomes `{"job": null, "config": {"auto_update": true}}` for it — old wheels keep
  receiving byte-identical `null`. The upgrade is strictly-newer-only, pinned to the
  exact target, verified by importing the new code in a FRESH subprocess before the
  process `execv`s back into itself (same PID, so the restart-on-failure unit policy
  is never consulted; a red verify stamps a 6h backoff and the box keeps serving on
  the running version). New `python -m yothere.selfupdate [--dry-run|--force]` as the
  manual escape hatch. Settings warns when a flag-on machine runs a wheel that
  predates auto-update (or cannot upgrade itself) and names the one-time fix.
  *Ships as:* control plane (toggle + envelope) now; the machine-side updater rides
  the wheel (next PyPI release) — one final manual `pipx upgrade yothere` per box,
  after which it keeps itself current.
- **Phone alerts you can trust, on iOS and Android.** Enabling the cockpit bell now
  ends with a REAL test push in your hand ("This is what a yothere alert looks like"),
  not an untestable promise; a new authed `POST /push/test` fans it out and the
  /settings page gains a **Phone alerts card** showing the observed state — devices
  subscribed, when the last alert was actually delivered and to how many devices
  (fan_out's own `push-last.json` receipt) — plus a Send-test button. On an iPhone
  Safari tab (where Apple ships no Push API outside an installed web app) the bell no
  longer hides: it teaches the Add-to-Home-Screen step. Funnel instrumentation gains
  two one-per-tenant milestones: `push_enabled` (bell subscribed) and `first_push_tap`
  (the service worker now reports a notification tap to `POST /push/tapped` — proof a
  push brought a human back).

### Changed
- **A blocking ask pushes immediately.** The 30-minute global nudge rate limit no
  longer applies to fresh blocking asks (the id+state seen map already guarantees each
  ask nudges exactly once); it still paces "finished" recaps. A single-thread push now
  carries the decision itself in the body ("<thread> — <question>", from the typed
  ask's prompt) and an honest "yothere: finished" title when it is a recap, and every
  single-thread push deep-links to its card. *Ships in two parts:* hosted sweeps get
  all of it at the next control-plane deploy; the local runner's copy of the rate-limit
  and push-copy changes rides the wheel (next PyPI release).
### Fixed
- **"Paired!" no longer claims a state the machine is not in.** The /activate approved
  page used to say the machine "is now connected... starts on your queued tasks within
  a few seconds", which is false for every first-time pairing: approval only mints the
  credential, and the machine is connected only once `yothere service` runs on it and
  polls /jobs/next. The page now says approval is one step from done, shows the exact
  command (`yothere service install`), and live-polls /device/list to flip to
  "Connected" the moment the machine first checks in (honest static copy with JS off).
  The same approved-but-never-seen state is now named everywhere instead of reading as
  broken: Settings -> Machines shows "approved, waiting for first check-in" plus the
  fix, `yothere devices` says what "never" means, and the cockpit's
  copy-the-ask-for-your-agent snippet now includes starting the service after approval,
  so an agent-driven pairing lands fully connected instead of paired-but-idle.
  *Ships as:* control plane (`fly deploy`) for the /activate page, Settings, and the
  cockpit ask; the `yothere devices` wording rides the wheel (next PyPI release).

## [1.27.0] - 2026-07-16

### Fixed
- **An opened result fold survives the cockpit's 1.5s re-render.** Clicking
  "full result" / "full update" (or the plan step tree, the diff patch fold, the
  checks log tail, a Done card's session fold, the "N tool calls" timeline) opened
  for about a second and then snapped shut: every SSE frame rebuilds the card list
  via innerHTML, and each rebuild replaced the `<details>` with a fresh closed one.
  The page now records each fold's open state (keyed by card id + position) and
  re-applies it on every rebuild, at all five server-rendered innerHTML sinks plus
  the client-built tool timeline. Restore is two-way, so a server-default-open fold
  (the red checks log tail) stays closeable. Pinned by a new behavioral e2e check
  (open the fold, wait out a re-render, assert it is still open).
- **A de-authorized machine stops itself instead of crashlooping forever.** Deleting
  the account (or revoking a machine in Settings → Machines) kills its device token
  server-side, but the leaser used to exit 1 on the resulting 401 — and its unit
  KeepAlives unconditionally, so an ex-customer's box respawned against the dead
  credential every 30 seconds for the rest of time. Now a SECOND consecutive 401 is
  terminal: the leaser prints what happened plus the exact removal commands, kills its
  in-flight turns and offers the leases back, exits 0, and best-effort stops AND
  disables its own unit (which also covers pre-1.27 always-KeepAlive units; the
  disable survives reboots). Fresh installs write restart-on-failure units
  (`KeepAlive.SuccessfulExit=false` on launchd, `Restart=on-failure` on systemd), and
  `service install` re-enables the label so re-pairing stays `yothere login` +
  `yothere service install`.
- **Voice answers capability questions about the TENANT's machine** (not the worker's
  own host), and a finished task's result is no longer re-offered or claimed ready
  before it is. (#248)

### Added
- **Account deletion ends on an honest offboarding page.** `/settings/delete` now
  redirects to a public `/goodbye` page that says what is gone server-side and names
  the half deletion cannot do — the installed wheel and local thread files on the
  user's own machines — with the exact cleanup commands (`python -m yothere.service
  uninstall`, `pipx uninstall yothere`, `rm -rf ~/.yothere`). The settings danger
  zone discloses the same thing before the delete, so "delete my account" reads as
  full offboarding and is one.
- **Voice signaling outcomes on the Prometheus endpoint** (supersedes the PR #132
  JSONL-ledger approach, rebuilt on the metrics registry that postdates it). New
  counters classify what the HTTP status counters cannot see: a TURN mint that
  degrades to STUN still returns 200 (`yothere_voice_turn_mints_total{side,outcome}`,
  per laptop/browser side), a failed ephemeral-Gemini mint silently falls back to
  the local key (`yothere_voice_gemini_mints_total{outcome,reason}`, reason is the
  exception TYPE, never the message — it can embed the key path), and the voice
  routes' 429/503s share status classes across different refusal reasons
  (`yothere_voice_offers_total{path,outcome}` across both the laptop-media offer
  path and the hosted-media room path: accepted / over_cap / over_limit /
  no_endpoint / rate_limited / not_configured / provider_error / at_capacity /
  dispatch_failed). Plus `yothere_voice_answers_total` (the broker connect event)
  and `yothere_voice_relay_seconds_total` (reported call duration; relay minutes
  = /60), so connect success rate and relay volume are one Grafana query, not a
  hand-grep of voice-cost.jsonl. All recorders are fail-open, label values are
  enum-ish and tenant-free, and reading is the existing private-port scrape — no
  new ledger file, no new CLI.

## [1.26.0] - 2026-07-16

### Added
- **The Done column can replay the whole session.** A Past card used to show ONE bare
  summary line for a finished thread; the initial ask, the back-and-forth, and the
  human's own answers were on disk (transcript.jsonl) and rendered nowhere. Each Done
  card now carries an expandable "session" fold that lazy-loads a new tenant-scoped
  `GET /thread/{id}/transcript` route (never part of the /live SSE payload) and shows
  the task (plan.md), the chronological turns — each worker result with a muted
  "7 tool calls · Bash ×3 … · 41s" line, the human's replies as distinct "you" rows
  with the question they answered and a took-recommendation marker, errors/timeouts
  as thin muted lines — and the final outcome as a proper result card. The HTML is
  rendered and escaped SERVER-side (`render.render_session_view`) and lands in the
  cockpit's fourth server-escaped innerHTML sink; the fetched view is cached per
  card, so re-expanding (and the 1.5s SSE rebuild) never refetches. Feeding it:
  `worker.turn.end` transcript rows now keep 4000 chars of the result (800 cut real
  answers mid-sentence) plus a per-turn tool summary counted through the progress
  sink, and — because a HOSTED turn runs in a throwaway mirror that is rmtree'd —
  the leaser posts the turn's transcript rows home on the existing `/result` call
  (last 50, each row bounded, server re-validates: worker.* kinds only, re-clamped,
  idempotent against a retried post). Rider: a Done card's long summary now gets the
  same fold treatment as the working card (`progress_html` un-gated for ready rows)
  instead of a bare clamped line.
  *Ships in two parts:* the route + session renderer + `/result` handler + cockpit UI
  are control plane (`fly deploy`); the transcript-row post and the richer end row
  (resume.py + cloud/agent.py) ride the wheel (next PyPI release). Hosted threads
  gain durable worker-turn transcripts only after the wheel upgrade; the route + UI
  work immediately for local threads and for the phil.* rows of hosted ones.

### Changed
- **The cockpit's repos chip now explains itself.** It was the only capability on the
  strip that never said what it meant: hosted hover on "2 repos" showed nothing (the
  advert carried only a count), and "no repos" named the gap without the fix. Now the
  wheel advertises workspace names as namespaced `ws:` tokens on the same `?caps=` poll
  advert (count-capped at 8, length-capped, comma-stripped so a hostile name cannot
  forge a protocol token, fail-soft on a registry hiccup), the control plane passes
  them through as `repo_names`, and the chip's hover lists which repos a delegated
  task could build in. A count-only advert from an older wheel gets an honest
  "too old to name them" line instead of silence, and the zero-repos state now states
  the fix (`yothere workspace add <name> <path/to/repo>`) the way the gh chip does,
  in the cockpit and in the local probe's fix line. Wire key pinned at source level
  (`test_repo_names_wire_key_matches_across_server_and_cockpit`) because the DOM e2e
  boots off mode and never executes the hosted machines branch.

### Fixed
- **Hosted voice login preamble says "New to yothere? Create your account", not
  "Have an invite code?"** — the copy predated open signup (the invite gate is gone)
  and pointed new users at a code that no longer exists. (#230)
- **Email test suites no longer write into the live local store** — run script-style
  (the documented mode; conftest guards pytest only), `test_email.py`,
  `test_email_flows.py`, and `test_budget_email.py` exercised the real
  `events.emit_error` failure path against `settings.auth_db`, appending one
  `email/send HTTPError` row to the operator's LIVE `~/.yothere/auth.db` per run
  (236 rows over 2026-07-09..16). Each suite now repoints `YOTHERE_HOME` to a
  tempdir at import, before any yothere module loads, and `test_email.py` pins the
  isolation as a checked contract.
- **release_gate's error-rate canary refuses the wrong store** — without a
  `DATABASE_URL` in the env, `yothere admin errors` reads the operator box's LOCAL
  sqlite (its own telemetry + the test noise above), which the canary then compared
  as if it were the deploy's errors (the 2026-07-16 false spike, +24 "prod" errors
  that were local test runs). `capture_admin_errors` now skips honestly with an
  arming recipe (fly proxy + `YOTHERE_DATABASE_URL`) instead of comparing garbage.
- **A killed leaser no longer freezes the tenant's queue for 15 minutes** (the
  2026-07-16 stuck-Working-card incident: a restart mid-turn left its leases
  `active` for the full 900s TTL, and because active leases occupy the planner's
  concurrency slots, NOTHING else was dispatched — every card sat frozen on
  "working", including brand-new questions that had never been leased). Three
  layers, each independently useful:
  1. *Graceful shutdown* — the leaser now handles SIGTERM (launchd restart, wheel
     upgrade, `pkill`, laptop shutdown) and Ctrl-C by terminating its in-flight
     harness subprocesses and releasing their leases, so a plain restart un-sticks
     the queue in seconds. Leases whose turn already committed its side effects are
     never released (that would re-run them — the L3-01 at-least-once rule); they
     keep the TTL backstop.
  2. *Host-driven lease keepalive* (`caps=hb`) — the leaser beats every running
     lease once a minute on a host clock, independent of what the model emits.
     The old heartbeat rode progress EVENTS, and one long silent tool call looked
     exactly like death.
  3. *Short leases for keepalive wheels* — the control plane mints 300s leases
     (five missed beats) for a `caps=hb` wheel instead of 900s, so even a hard
     kill (power loss, `kill -9`) un-sticks the queue in about 5 minutes. Old
     wheels keep 900s: their only heartbeat is event-driven, and a shorter TTL
     could reap a live-but-quiet turn and double-run it. A new `ttl` column on
     `job_leases` (self-migrating, DEFAULT 900) makes heartbeats re-arm by the
     lease's own minted window rather than a hardcoded constant.
- **A zombie chat lease no longer starves the planner deterministically** (the
  starvation half of the same incident). plan_tick's already-advancing skip only
  tests the WORK live set, so a chat-leased thread stayed a candidate; being
  resumed it ranked first, plan_next picked it on every poll, `try_lease` lost to
  its own active lease on the unique index, and `_try_lease_next` returned None
  without trying the next candidate — so while one dead chat lease burned its
  TTL, every other runnable thread sat unleased. Chat-live rows are now dropped
  from the planner's candidate set (their lane capacity accounting is unchanged).
  Pinned by `_run_zombie_chat_lease_starvation` in tests/test_byo_queue_routes.py,
  control-verified to fail on the unfixed planner.
- **Leaser observability for exactly this class of incident**: a `run_once` that
  raises outside its own try (bad envelope, materialize failure) used to vanish —
  the pool future held the exception and nothing called `.result()`; it is now
  printed to stderr. And stdout is line-buffered, so `leased ...`/WARNING lines
  survive under launchd instead of dying in a block buffer at every SIGTERM (the
  leaser's `.out` log was 0 bytes across a week of incidents).

### Changed
- **Cockpit voice CTA moved into the dispatch bar** — the quiet header "Connect"
  button is now a prominent green "Connect voice" button (mic glyph) sitting at the
  left edge of the dispatch pill, with the mute button clustering next to it during
  a live call. On connect, the bar shows the marketing site's "Voice connected"
  animation (pulsing green dot + staggered waveform, `prefers-reduced-motion`
  fallback), ported from the site's dispatch-bar mock so the product matches what
  yothere.ai advertises. Also fixes the Hang-up button never actually turning red
  (the `#connect` id rule outspecified `button.hang`). All mode states carry over:
  Connecting…, Hang up, hosted "Start voice on your laptop", off-mode unavailable.
  On phones the bar wraps: input on row 1, voice controls + Dispatch on row 2.

### Removed
- **The legacy pairing paste block is gone from the UI entirely.** /overview and
  /onboarding now offer exactly one pairing path: the browser device flow
  (`yothere login` → short code → approve at /activate). The collapsed
  "self-host or headless" fold, its permission ladder, the `_PAIR_CMDS_JS`
  block renderer, and the cookie-gated `POST /device/pair` mint route are all
  removed. Rationale: a pasted block that instructs a coding agent is the exact
  shape agents are trained to refuse (five refusals across four block rewrites),
  and the device flow already covers headless/SSH-only boxes — the approval
  browser can be on any device. `POST /device/claim` stays: operator-minted
  tokens and pre-1.24 CLIs still redeem there.

### Fixed
- **A raised permission tier now actually reaches a claude thread's beliefs.** The
  tier-change session reset was codex-only; a claude session that ever ran a turn at
  a restricted tier carried that tier block (and its own refusals, and the block's
  "a tier denial is FINAL, do not retry" instruction) in its resumed history forever.
  Raise the machine to full and nothing announced it — the model kept declining shell
  and MCP work the machine now allowed and reported the OLD tier to the user as fact
  ("this machine's workspace tier does not allow shell commands" on a cockpit that
  showed full). Worker turns now stamp the session's tier and, when the enforced tier
  stops matching, append a tier-update note to the system prompt on every later turn
  of that session. Pre-existing sessions have no stamp and cannot be repaired
  retroactively; start a fresh thread (or send one corrective reply) for those.
  Worker-side: reaches BYO machines with the next PyPI release.
- **The restricted-tier directive names the knob that actually turns.** When the
  machine's own env/vt_config cap (not the cockpit grant) forced the downgrade, the
  directive claimed "the user chose this in Settings → Machines" — a claim a
  full-in-the-cockpit user can disprove in one glance. The copy now attributes a
  local cap to the machine-local setting, and every restricted turn records
  tier + source in the thread transcript plus one worker-log line with both merge
  inputs.
- **The cockpit's session-card × now works on app.yothere.ai.** The dismiss route
  only consulted the in-process call manager, which is always None on the media-less
  control plane, so every hosted click 404'd — silently, because the frontend
  swallowed the failure. Dismissals now persist on the live call row (filtered at
  read time against the worker's snapshot re-pushes, cleared with the call), and the
  × surfaces errors like every other affordance.
- **Marking a task done says so when nothing can run the write-back.** The hosted
  ✓ enqueues a job that is only served to an online full-access machine with a
  tasks-capable wheel; with none eligible it queued forever while the chip muted for
  120 s and silently un-muted. The response now carries `held` with the reason and
  the cockpit shows it instead of muting the chip.

### Added
- **`yothere admin pair-token <email>`** — the only remaining mint path for a
  single-use pairing token (60-minute expiry, sha256-only at rest), for
  unattended provisioning and self-host break-glass. Store-direct like
  `admin whitelist`: run it ON the control plane (`fly ssh console` or the
  self-host container). Flags: `--tier observe|workspace|full` (default
  observe), `--send contained|open` (default contained), `--label`, `--url`.
  The pre-device-flow fallback hint in `yothere login` now points here instead
  of the removed cockpit button.
- **Prometheus metrics on a private port, scraped by Fly's managed Grafana**
  (readiness plan Phase A2 — the observability gap ahead of the 100-user load test).
  `yothere.cloud.metrics` hand-rolls the text exposition format (no new dependency)
  and serves it from a stdlib listener on port 9091, which `deploy/fly.toml`'s new
  `[metrics]` section points Fly's collector at over the private 6PN network — the
  port is never publicly mapped and the FastAPI app never mounts `/metrics`, so the
  endpoint has no public route and needs no auth. Instruments exactly the walls the
  capacity audit named: per-route-template request counters + full-stack latency
  histograms (an outermost middleware; status CLASS labels only, unmatched paths
  collapse to one fixed label so a bot scan cannot mint series), event-loop lag (a
  0.5s self-timing ticker the middleware lazily arms), pgpool checkout telemetry
  (`get_stats()`: size/available/waiting plus cumulative wait-ms), long-poll
  concurrency + the fleet-scan semaphore depth (webrtc_server's own gauges),
  retention pass duration + outcome (fed fail-open from `run_forever`), `/data`
  disk usage, open fds vs RLIMIT_NOFILE, active voice rooms (a new
  `VoiceStore.count_active_calls`, Postgres deploys only), and a `build_info` sha.
  Hosted-only listener (started by `web.main`), kill-switch
  `YOTHERE_METRICS_DISABLED=1`; recording is fail-open everywhere — a metrics bug
  can never break a request, a scrape, or the retention loop. The voice worker
  (`yothere-voice-worker`) runs its own server without this middleware and is not
  wired in this change.
- **Full funnel instrumentation + NSM surfaces** (analytics wave 2). The activation
  funnel grows four one-per-tenant milestones at the chokepoints that were dark:
  `machine_paired` (both pairing paths — a pairing-token `/device/claim` and an
  approved device-flow `/device/poll`), `first_result` (the first `/jobs/<lease>/result`
  that posts state `done` — a blocked ask does not count), `first_steer` (the first
  reply/accept/ship action on an existing thread; the dispatch that created a thread
  still counts toward the NSM's usage_events but never latches this stage), and
  `first_voice_call` (`/voice/offer` + `/voice/room`). The voice surfaces now record
  NSM steers too: a spoken thread reply and a spoken inbox answer count exactly like
  their cockpit chips (a dismiss stays housekeeping). `yothere admin funnel` adds the
  new stages plus a signup-cohort conversion table by ISO week (`--json` gains a
  `weekly` list; the first five text lines stay byte-stable for the existing
  collector), and the new `yothere admin digest` emits funnel + weekly cohorts + the
  NSM report as ONE JSON so bob's cron makes one round-trip instead of three. Every
  emit stays fail-open (a store error can never break a request). New milestones ride
  `activation_events`, so they inherit the existing retention regime (account
  lifetime, pseudonymized on account deletion — ROPA row 6 updated) with no new sweep.
  Site analytics unchanged: the marketing site already runs cookieless PostHog EU
  behind `site.posthogKey`; the cockpit stays first-party-events only.

### Fixed
- **A hung worker is no longer free: a turn killed at the wall clock (or crashed before
  its result frame) now books the spend the stream actually observed.** `claude -p`
  reports `total_cost_usd` only on the terminal result frame, so a wedged turn that was
  SIGKILLed at the timeout reported no cost at all — the BYO leaser posted `$0`, the
  per-thread and daily cost caps never accumulated, and a wedge-looping thread ran
  invisibly and for free (the 1.18.5 dogfood finding). The streaming backend now
  accumulates the per-message `usage` frames every assistant message already carries
  (`harness/usage.py`, deduped by message id) and prices them with the same arithmetic
  that reproduces the binary's own `total_cost_usd` on the captured fixture transcript —
  an observed floor, never a fabricated estimate. The spend rides the timeout exception
  into the turn engine, lands in the summary's `cost_usd`, and both ledgers book it (the
  local runner's cost log and the hosted tenant ledger via `/jobs/<lease>/result`), so
  the caps can finally stop a hung worker. A frameless crash gets the same treatment via
  `BackendResult.cost_usd`.
- **A machine that goes quiet mid-turn is now visible on the thread card, and a turn
  that repeatedly kills its machine surfaces as a failed thread instead of re-leasing
  forever.** When a leased turn's machine died (laptop OOM, kill -9, power loss), the
  lease expired silently: the cockpit showed a healthy "running" pulse throughout, the
  thread re-leased, and a machine-killing turn looped without end. The supervisor tick
  now surfaces every expired turn lease on its thread — `worker_quiet_at` renders as an
  amber "machine went quiet Xm ago mid-turn" line on the Working card (cleared the
  moment a machine leases the thread again) — and counts consecutive expiries: after
  `MAX_LEASE_EXPIRIES` (3, env `YOTHERE_MAX_LEASE_EXPIRIES`) the thread parks as a
  visible stuck card ("the machine running this task went quiet N times in a row")
  instead of re-leasing, so a killed worker surfaces as a failed thread within one
  supervisor tick + fleet scan. An accepted result resets the count; a reply retries.
  Publish-lane leases are exempt (deterministic + idempotent). Mirrors the existing
  in-process crash give-up (`cloud.agent.MAX_TURN_FAILURES`), which cannot count crashes
  that take the whole leaser down.

### Changed
- **Hosted voice one-click: busy is not down, and a zero-machine caller is told so
  upfront** (W2). Three related fixes to the fresh-tenant Connect path. (1) POST
  `/voice/room` now tells the worker's at-capacity decline apart from a dead worker:
  the media worker's typed 503 "worker at capacity" (the ~4-concurrent-call ceiling)
  surfaces as 503 `voice at capacity` with a `Retry-After`, a failed call row, and a
  countable `voice room at capacity` log line — call #5 gets a deterministic "busy",
  never the generic unavailable — and a Daily API failure now answers 503
  `voice provider error` (row failed) instead of escaping as a raw 500. The cockpit's
  connect alert reads the detail and says "voice is busy (every line is in use), give
  it a minute" vs "voice is down, we're on it" instead of "room rejected: 503". (2)
  The tenant's live-compute signal (`_compute_available`) now rides the worker
  dispatch (`compute_available` → `YW_COMPUTE_AVAILABLE` → SessionManager
  `compute_at_join`), so a hosted call that starts with no paired machine says so IN
  THE GREETING instead of the agent discovering it after the first task queues into a
  queue nobody leases. (3) A new `NO MACHINE CONNECTED` prompt section arms the
  voice-guided pairing walkthrough (the on-screen "Connect a machine" panel;
  `pipx install yothere`, `yothere login`, `yothere service`), spoken step by step —
  the one setup answer the agent gives without dispatching, since a dispatched
  explainer could never run — and re-verifies only through a dispatch result. Absent
  signal (older control plane / local planes) keeps the prior greeting byte-identical.
  Pinned by `tests/test_voice_room_errors.py` (wired into CI) plus new checks in
  `tests/test_voice_worker.py` and `tests/voicecall_test.py`.
- **Site repositioned around the hands-free scenario.** New hero ("Your agent fleet,
  hands-free."), a multi-brain second beat (one cockpit for Claude Code and Codex), a
  read-the-diff-then-ship beat, and a trust floor (send-gate, per-machine permission
  tiers, browser device-flow pairing, BYO subscription). The canonical GEO boilerplate
  is replaced everywhere it appears (site footer, meta descriptions, README top,
  `llms.txt`, `for-agents.md`, the PyPI description in `pyproject.toml`). Messaging-
  channel claims (Telegram/WhatsApp) are cut from the site and the send-gate docs now
  say "mail and messaging send CLIs" without naming brands; the `YOTHERE_NOTIFIER`
  rows leave the configuration page. Also retires the forbidden "Nothing leaves that
  you didn't send" line, the "Voice-first" feature label, and the README's stale
  invite-only wording (signup is open behind the Founding paywall). Copy only; no
  billing mechanics touched.
- **First-run funnel: pairing first, honest feedback, no more native dialogs** (from the
  2026-07-16 UX review of the first 30 minutes). Five fixes: (1) `/onboarding` now leads
  with step 1 "Connect your machine" (pipx + `yothere login` + the agent one-liner) and
  takes the first task as step 2 — under BYO compute a first dispatch with no machine
  queued into silence; when a machine is already connected the page says "Machine
  connected ✓", dims step 1 and hands the autofocus to the task box (reuses the live
  per-tenant compute check, no new endpoint). (2) The cockpit's post-dispatch toast is
  gated on the same compute signal as the banner: with no live machine it now says
  "Queued — connect a machine to run it" with a button that opens the pair panel,
  instead of "watch it under Working" pointing at a thread that cannot start. (3) The
  `/login` card states the value prop under the wordmark ("The hands-free cockpit for
  your coding agents."). (4) All three permission ladders (pair panel, onboarding,
  `/activate`) now mark Read & research as the preselected safe default and name the
  raise-later path (Settings, Machines) — copy only, no enforcement change; unchecked
  was already observe. (5) Every native `prompt()`/`confirm()` in the cockpit (Edit
  draft, Revise, Follow up, Request changes, the diff/PR discards, thread Delete) is
  replaced by a styled in-page modal (Enter submits, Escape cancels, destructive verbs
  keep an explicit confirm); since the modal, unlike `prompt()`, no longer freezes the
  SSE loop, `resolveAsk` re-resolves the live card by thread id. Pinned by the new
  `tests/test_funnel_fixes.py` (wired into CI). The header ⚙ → `/settings` swap the
  review also asked for already ships (hosted mode only; off mode has no `/settings`).
- **The Founding plan's voice allowance is now stated everywhere the plan is sold: 120
  minutes a day, resetting daily.** The in-app paywall gained a "Hosted voice included,
  120 min a day" bullet plus a fine-print reset note, and the website (pricing card,
  pricing-page footnotes, FAQ, docs, terms, `llms.txt`) drops the old "daily cost cap
  rather than a minute allowance" framing for the explicit daily allowance. Copy only;
  the metered enforcement is a separate change. Also swept six `$39` stragglers in
  `llms.txt` that the price cut (#232) missed, and corrected the stale "free of charge"
  beta line in the terms.
- **Scale hygiene ahead of a wider beta: two unbounded per-tenant files now get swept,
  and the Fly proxy is told about the long-poll workload.** A readiness pass over the
  single-box control plane found no death-at-100 landmines (pooling, timeouts, per-tenant
  rate limits, tenant isolation, BYO-compute are all sound) but three cheap gaps worth
  closing before traction: (1) `rate_events` (the device-flow poll ledger, ~120 rows per
  pairing) was append-only and never pruned, so the trailing-window `COUNT` in
  `rate_limited()` degraded for any recurring key — the hourly retention pass now prunes it
  past a 24h window (`prune_attempt_history` gained an optional `rate_before`). (2) Each
  tenant's cost ledger is re-read IN FULL by the planner on every `/jobs/next` poll, yet the
  hosted plane never compacted it (only the off-Fly runner did) — the retention pass now
  calls `compact_cost_ledger` per tenant, bounding that hot-path read to a 7-day window.
  (3) `deploy/fly.toml` now sets an explicit `[http_service.concurrency]` (connections,
  soft 400 / hard 600) so Fly's proxy stops shedding held long-poll/SSE connections at its
  low default before the event loop is the real wall. Pinned by new assertions in
  `test_retention.py` (`rate_events` prune + end-to-end cost-ledger compaction in the pass).

### Added
- **Daily voice-minutes limit (120 min/day per user) with a usage meter in Settings.**
  Every voice call runs on hosted keys, and until now nothing bounded a tenant's daily
  consumption unless the (still-unarmed) USD billing cap was on. The control plane now
  derives each tenant's minutes from the privacy breadcrumb it already keeps
  (`picked_at`/`ended_at` on `voice_calls` — no new data recorded, nothing new to retain
  or purge; per-call duration clamped at the 30-min call ceiling so an orphaned row can't
  eat the budget), refuses new call starts with `429` + `Retry-After` on both `/voice/offer`
  and `/voice/room` once the budget is spent (in-flight calls always finish), and renders
  a fill bar in `/settings` — X of 120 minutes used today, amber at 80%, red at the cap,
  resets at midnight UTC. The cockpit explains the refusal in plain words instead of
  "rejected: 429". Knob: `YOTHERE_VOICE_DAILY_MINUTES` (default 120; `0` disables and
  hides the card). New `cloud/voice_usage.py` + `VoiceStore.today_voice_seconds`; suite
  `tests/test_voice_usage.py` wired into the core and voice CI jobs.
- **Error tracking (Sentry) for the hosted control plane — inert until armed.** The
  control plane's only "is it working" surface was `/healthz` (binary DB up/down), the
  EventStore 500-record, and stderr in the Fly log stream, so a partial degradation was
  invisible until it flipped health or a user complained. New `yothere.cloud.observability`
  arms sentry-sdk's FastAPI auto-integration from `web.main()` *before* `build_app`, so
  unhandled 500s (the same ones `_capture_errors_mw` records to the EventStore) also raise a
  human-facing alert with the deploy SHA as the release tag. Fully **inert by default**: with
  no `YOTHERE_SENTRY_DSN` set it never imports sentry-sdk, so off-mode laptops and un-armed
  deploys carry zero behaviour and zero cost. PII stays out (`send_default_pii=False`,
  `max_request_body_size="never"`) to hold the privacy S1 posture — only route + exception
  type + release travel, the shape the EventStore already keeps. Arm with
  `fly secrets set YOTHERE_SENTRY_DSN=... -a yothere-cloud`. Knobs: `YOTHERE_SENTRY_ENV`
  (default `hosted`), `YOTHERE_SENTRY_TRACES` (default 0.0, errors only). `sentry-sdk>=2`
  added to the `web` extra (ships in the Fly image, inert until the DSN exists). Inert-by-
  default contract pinned by `test_observability.py`; the armed capture path was verified
  end-to-end locally (a route 500 is captured as its real exception type, PII off).

## [1.25.0] - 2026-07-16

### Added
- **Pair a machine in your browser (device flow), no token to paste.** `yothere login` now
  defaults to an RFC 8628 browser device flow: it prints a short code and an
  `app.yothere.ai/activate` link, and the workspace owner approves in their own logged-in
  browser, where they see which machine is asking, pick its permission tier (fail-closed to
  read-only), and for the full tier tick a bypass-consent checkbox. No secret and no
  agent-directed prose ever passes through the coding agent, which closes the
  paste-a-block-that-instructs-the-agent refusal at the root. The full-tier consent is recorded
  on the machine so `yothere service` starts without re-asking. The legacy
  `yothere login --url .. --token ..` path stays for self-host and headless boxes; `--url` now
  defaults to `https://app.yothere.ai` and `--token` is optional. New store (`device_codes`),
  control-plane routes (`/device/code`, `/device/poll`, `/activate`), and a reduced cockpit
  paste block. Pinned by `test_device_codes.py`, `test_activate_routes.py`,
  `test_login_claim.py`, and `test_pair_block.py`.

## [1.24.0] - 2026-07-15

### Changed
- **The cockpit and every account page now wear the brand.** The web app was still on the
  old dark GitHub/Primer palette (purple accent, system fonts, a flat purple-square favicon)
  while the marketing site had moved to the "slate" brand. Ported the whole app to that
  brand: light paper ground (`#edf0f5`), lilac accent (`#6d28d9`), self-hosted Switzer type,
  and the orange brand caret as the favicon. This touches the cockpit stylesheet
  (`voicecall/overview/style.css`, a full dark→light token port), the rich plan/result card
  CSS (`card.py`/`render.py`), and all ~12 inline account pages (login, signup, onboarding,
  settings, billing, forgot/reset, notice) — whose primary buttons were an inconsistent
  blue-vs-purple mix and are now one lilac. Every page also gains the caret favicon it
  lacked. New static routes serve the caret (`GET /favicon.svg`) and the Switzer cuts
  (`GET /fonts/switzer-*.woff2`, allowlisted); `pwa.icon_png` now draws the caret and the
  manifest `theme_color`/`background_color` are the slate ground.

### Fixed
- **A task that repeatedly crashes now surfaces instead of looping invisibly.** When a
  worker turn *raises* a hard error (a crashed subprocess, an unhandled exception — not a
  normal error the engine already maps to `stuck`), the leaser used to blindly hand the
  lease back and re-lease forever, so a persistently-failing task burned turns with no card
  ever appearing. It now retries silently up to twice (a transient crash usually clears),
  then on the third consecutive crash surfaces the thread as a `stuck` card naming the error
  (Resume to try again). The consecutive-crash count rides home on the thread status
  (`turn_failures`) via `/jobs/<lease>/release`, so the give-up rule survives across leases;
  any turn that completes without raising resets it. A lease that *expires* (an infra
  restart) is deliberately not counted — that should still auto-retry. Pinned by
  `tests/test_turn_failure_giveup.py` + the `/jobs/release` route test.
- **Orphaned turn mirrors no longer accumulate.** A leaser killed mid-turn skips the
  `run_once` cleanup, leaving its throwaway `yothere-job-*` mirror dir behind; over many
  restarts these piled up unbounded. The leaser now sweeps stale mirrors (older than the
  lease TTL, so a live turn is never touched) at startup and hourly.
- **A dispatched task that finishes now surfaces its result instead of vanishing.** When a
  task you dispatch from the cockpit (or by voice) ends with a *result* rather than a
  *question*, it used to fall silently out of the active view: a `done` thread derives no
  ask, so nothing landed in the Inbox and the card just disappeared. The overview now
  re-raises a freshly-dispatched standalone task's result as a **non-blocking `fyi` Inbox
  card** — shown, dismiss-only, no "needs you" badge — so you actually see the answer
  without being nagged. Tightly scoped so it does not revive the old done→Inbox bounce: it
  fires only for a thread you dispatched (carries an utterance, no linked task), with a
  real summary, that you have not reviewed, and that finished recently. A delegated
  (task-linked) done keeps its existing Past "Mark task done" flow; an ambient or stale
  finish stays in Past. A result is therefore always in one place or the other — the Inbox
  while fresh, Past once seen or aged — and never simply gone. Pinned by
  `tests/test_dispatch_result_surfacing.py` (wired into CI) and the cockpit DOM E2E.

## [1.23.1] - 2026-07-15

A worker-side reliability fix.

### Fixed
- **The chat lane no longer confabulates when a lookup comes up empty.** Short typed
  questions ("check the last conversation with X", "who is this person") run in the chat
  lane, whose system prompt had no grounding rule. When the worker could not actually
  retrieve the source, nothing stopped it from inventing a plausible answer and narrating a
  step it never took. The chat lane now carries the same anti-hallucination contract the
  voice and dispatched-task lanes already had: answer data questions only from tool output
  this turn, say "I couldn't find it" rather than guess, and never fabricate a source or a
  step. `thread-system.md`'s summary faithfulness clause is likewise extended to reads.

## [1.23.0] - 2026-07-15

The privacy release. A GDPR-oriented pass over what yothere stores, for how long, and how
it is erased, driven by early testers whose main concern was the privacy of their
conversations. Most of it is control-plane and website (already live); the wheel-side
piece is the local-artifact hygiene below.

### Added
- **`yothere local wipe`** erases the content yothere keeps on your own disk — call
  transcripts, failed-turn logs, call debriefs. `--harness` also prunes codex rollouts and
  your own (age-bounded) Claude Code session transcripts; `--dry-run` / `--yes`.
- **Per-thread delete** in the cockpit: a settled thread can now be truly erased (staged to
  a trash dir, hard-deleted after a grace window), distinct from Dismiss (a recoverable
  archive).
- **Operator data-subject tooling:** `yothere admin export` (GDPR access/portability) and a
  full-cascade `yothere admin delete`.

### Changed
- **Voice call content no longer persists on our servers after a call.** Every terminal
  transition clears the transcript, session snapshot, and network-signaling data from the
  database atomically; the hosted voice worker's own disk is configured to never write call
  content (`YOTHERE_CALL_LOG_DISABLED`).
- **Retention by default:** an hourly control-plane pass enforces windows on voice metadata
  (30d), finished task rows (7d), auth/throttle history (24h–7d), error logs (90d), usage
  (13mo), and email events (180d), plus a two-stage reaper for deleted tenant homes.
- **Account deletion now reaches every table** — content-bearing telemetry is deleted and
  aggregate rows are pseudonymized with a random per-deletion token.
- **Local content artifacts are age-bounded**, not only count-bounded, and the BYO leaser
  now runs the hygiene sweep. Call debriefs moved from `~/.local/share/ubob/relay-debriefs`
  to `$YOTHERE_HOME/logs/call-debriefs`.
- **Waitlist minimization:** the signup record no longer stores your user agent and expires
  after 12 months.

### Security
- The remote-brain bearer token (the one credential the control plane must replay) is now
  encrypted at rest with Fernet when `YOTHERE_DB_CRYPT_KEY` is set.

## [1.22.0] - 2026-07-15

The "build a feature by voice, review the diff on your phone, ship it, watch CI, merge,
deploy" capability, reviewed and made honest. The pieces landed incrementally across the
1.20-1.21 line; **1.21.0 was yanked** because it shipped parts of the publish rail before
the security review below had run. This is the first release to carry the whole rail after
that review.

### Added
- **Build a feature by voice, ship it from your phone.** Dispatch a feature into a registered
  git repo; hear the plan read back and approve it; watch it build in its own git worktree;
  review the diff on a card; say (or tap) "Ship it"; watch CI; merge on green; deploy. Works
  on Claude or Codex, and end to end by voice. No terminal. (Sessions 1-6: registered
  workspaces + per-thread worktrees, the code lane, the diff card, the deterministic host-side
  publish rail with zero credential custody, CI-watch / merge / opt-in deploy, and voice
  parity for every card.)
- **Capability model + guided setup (Session 7).** Each machine probes what it can actually do
  (git, `gh` auth, installed harnesses, connected repos, wheel currency) and advertises it, so
  the cockpit only offers buttons the box can back and answers for the machine that will RUN
  the work. Missing GitHub auth is an offer, not an error: a host-side OAuth **device flow**
  signs you in with no TTY, and the token never touches disk, a log, argv, or `status.json`.
  Onboarding gained a "connect a repo" step.
- **Hold music on voice connect**, iterated for a gentler arrival and content-hash-cached so a
  new track actually reaches listeners.

### Security
- **Adversarial security review of the whole publish rail (Session 8).** Nine attack lenses,
  each trying to make a worker turn cause a push; every claimed break independently
  majority-refuted before being acted on. Two host-rail confused-deputy bugs were found and
  **fixed** (each watched red before green, wired into CI):
  - A worker turn could plant `status["publish"]={"op":"push_pr"}` (the publish trigger rides
    the worker-writable `status.json`) and the host publish lane would push / PR / merge with
    **no human click at all** — exploitable even from a read-only tier. The control plane now
    strips the publish-rail keys (`publish`, `checks`, `pr_url`, `publish_error`) from any
    non-publish-lane result, and a work turn's status is reset to its pre-turn values host-side
    after the turn; only a real `lane="publish"` job may carry them.
  - A worker turn could rewrite `~/.yothere/workspaces.json` to poison a workspace's
    `deploy_cmd`, which a later human **Merge** click then ran host-side. `deploy_cmd` is now
    the snapshot captured from the registry at **dispatch**, host-side before any turn runs,
    never a value re-read at merge time.
- **The publishing claim is now honest across all three trust surfaces** (`SECURITY.md`, the
  pairing block, the onboarding consent text). The review confirmed the full-tier send gate is
  best-effort argv inspection, not a sandbox: a determined worker turn can push around it (a
  commit hook, a `git` alias, a renamed binary, a mutating MCP tool, or by overwriting the
  gate), because it runs as the OS user. The surfaces now split the ABSOLUTE claim (the
  approval mechanism never authorizes a push; yothere holds no credential; the human's click
  runs host-side and a turn cannot forge it) from the BEST-EFFORT one (the in-turn deny at the
  full tier; the boundary there is the OS account; the restricted tiers do contain it). A test
  ties all three surfaces together so they cannot drift.

### Fixed
- The junk-thread flood, root-caused: the voice test suite could write the live fleet when run
  script-style, and a replayed voice dispatch minted a duplicate thread. Both fixed, with a
  standing rule (never run a test suite script-style against the live `YOTHERE_HOME`).
- A finished code turn holding a real committed diff now re-enters the Inbox instead of
  silently landing in Past, unreviewed and unpublished.

## [1.21.2] - 2026-07-14

### Fixed
- **A new hold-music track could never reach anyone.** The asset is served with
  `Cache-Control: max-age=604800` under a stable name, so after 1.21.1 shipped a new loop,
  Cloudflare kept handing out the OLD one (`cf-cache-status: HIT`, `age: 3673`) while the
  origin already served the new bytes — a week-long stale window on every future track change.
  The page now requests the music by its **content hash** (`/hold-music.mp3?v=<sha256[:12]>`,
  stamped into the page server-side): new bytes are a new URL, so the edge misses once and may
  then cache forever. Keyed on the bytes, not the version — the asset can change without a
  release, and a release can carry no new asset.

## [1.21.1] - 2026-07-14

### Changed
- **Hold music: happier track, half the volume, a gentler arrival.** The waiting loop is now
  "City Sunshine" (Kevin MacLeod, FreePD.com, CC0; 40s self-crossfaded mono loop at -24 LUFS)
  instead of the calmer "Study and Relax", at gain **0.075** (half of 1.21.0) with a **3s**
  fade-in instead of 1s. The first pass also **opens past the loop seam**: the asset's first 3s
  are its tail crossfaded into its head (two parts of the song at once) — right to wrap through,
  wrong to open on, so playback starts at `HOLD_START_S` and only the wrap ever plays the blend.

## [1.21.0] - 2026-07-14

### Added
- **Hold music on voice connect**: the cockpit fades in a quiet waiting loop (gain 0.15,
  1s fade-in) from the Connect click until yothere is first HEARD, then fades it out
  (500ms) — bridging the boot wait, worst-case the ~18s hosted cold start. Served at
  `/hold-music.mp3` (public static, all modes) and stopped by the `clearBootStatus()`
  safety net so it can never outlive a failed/aborted connect. Track: "Study and Relax"
  by Kevin MacLeod (FreePD.com, CC0 public domain; site closed 2025, retrieved via the
  Internet Archive), 42s self-crossfaded mono loop normalized to -24 LUFS.

## [1.20.0] - 2026-07-14

Per-machine permission tiers: pair a machine read-only, raise it when it earns trust.

### Added
- **Permission tiers per paired machine** (`observe` read-only / `workspace` / `full`),
  chosen as a checkbox ladder at pairing (cockpit pair panel + onboarding), stored on the
  device row, and editable in Settings → Machines. New pairings default to read-only.
- Worker-side enforcement that binds even over a box's own `~/.claude` allow rules:
  generated permission deny rules (`Bash`, `mcp__*`) + a new PreToolUse path gate
  (`worker_hooks/tier_scope_gate.py`, fail-closed on crash) on Claude Code; the native
  `workspace-write` OS sandbox on Codex with `/tmp`, `$TMPDIR`, and network excluded
  (observe turns run with the task scratch dir as their sandbox). Restricted tiers deny
  shell commands and MCP tools on Claude Code (a tool name cannot prove an MCP call
  stays in scope); on Codex both run inside the OS sandbox. The local env
  `YOTHERE_WORKER_PERMISSION_MODE` is a hard cap the server can never raise above.
- The `/jobs/next` envelope carries `permissions` for `caps=perms` wheels; a RESTRICTED
  device on a wheel too old to enforce tiers is held (never leased) and Settings names
  the fix. The pairing block (v5) now renders per-tier capability text.
- Restricted turns get a tier directive in the system prompt: finish what the tier
  allows, then explain the limitation and point at Settings → Machines — never hunt for
  a route around a denial. The send gate's deny message does the same on restricted
  machines.

### Changed
- `codex` threads drop their recorded session when the machine's tier changes (codex
  sessions inherit the sandbox they were created under); the session's tier rides the
  result envelope home as `session_tier`.
- Hosted operator-run tenants (clamped to `default` mode) now resolve to the observe
  tier: scoped writes work (status.json lands via the gate's explicit allow) and Bash is
  hard-denied — stricter and more functional than the old mode-only clamp.

## [1.19.1] - 2026-07-14

A provider hiccup no longer strands your approved send, and the voice agent stops
re-offering results it already read you.

### Fixed
- **A transient model-API failure mid-turn now retries instead of wedging the thread.**
  A "Send it." turn died 51s in on `API Error: Server error mid-response` and the
  approved WhatsApp draft stranded behind a stuck card (2026-07-14). Worker turns now
  classify that shape as `api_error` and retry in-process (bounded by
  `YOTHERE_MAX_API_ERROR_RETRIES`, default 2, with backoff) — but only while the worker
  has not landed a terminal status and, on a send-approved turn, only while the
  single-use send grant is provably unclaimed (`<grant>.used` absent). The grant is
  re-minted fresh per attempt from the same approval. A CLAIMED grant never retries:
  the thread stops with "it may or may not have gone out, check before re-approving".
- **Stuck cards now carry the harness's own error text.** "The worker's harness
  reported an error before it finished." alone sent a human grepping logs; the terminal
  frame's `result` (the actual cause) was persisted but never rendered. It now rides
  `what_blocked_me`, and a dirty send turn with an unclaimed grant says plainly that
  the draft was NOT sent.
- **The voice status nudger no longer re-offers delivered results.** After
  `check_results` read a result to the user, `session_states()` kept it in `done`, so
  while anything else ran the nudger kept injecting "These are ready if you want them:
  ..." — the "I still have the event details ready, do you want to hear it?" loop.
  Delivered results now sit in their own bucket the nudger ignores; a follow-up
  re-arms them, and explicit re-reads (`check_results` / `read_full_result`) still work.

## [1.19.0] - 2026-07-14

Mute yourself on a call, from the cockpit or by asking.

### Added
- **A mic button in the cockpit, and `mute_mic` so you can just say "mute me".** It is a
  HARD mute: the browser detaches the mic from its `RTCRtpSender` (`replaceTrack(null)`)
  and a new `MicGate` in the pipeline drops any `InputAudioRawFrame` that arrives while
  muted. Nothing said while muted reaches Gemini, and nothing leaves the browser — the
  e2e proves it at the wire, asserting outbound RTP flatlines while muted (with the
  unmuted rate as the control) and resumes on unmute.
- **There is deliberately no unmute TOOL, and that absence is the design.** A hard mute
  means the audio carrying the word "unmute" never reaches the model, so an unmute tool
  could not fire; shipping one would advertise a way back that does not exist. The single
  `mute_mic` tool's rule instead forces the model to name the real way back (the cockpit
  button) BEFORE it goes deaf. A test fails if anyone adds `unmute_mic`.
- **Talking into a dead mic now tells you so.** `replaceTrack(null)` leaves the mic track
  live locally (unlike `track.enabled = false`, which would silence it locally too), so an
  analyser still hears you and shows "You're muted. Tap the mic to come back." This is the
  compensation for having no spoken unmute.

### Fixed
- **Muting now also holds proactive speech, which is correctness, not politeness.** The
  nudger fires off `FloorGuard.quiet_secs()`, and that clock only resets on a user
  `TranscriptionFrame` — none of which arrive while muted. Cutting only the mic would have
  left the quiet clock growing without bound, so yothere would have monologued at a dead
  microphone on every cooldown. `_inject` (the single chokepoint all four proactive loops
  push through) is now gated on the mute; a sentence already in flight still finishes.

### Notes
- Two traps found by probing pipecat 1.4.0 rather than trusting the docs. `InputAudioRawFrame`
  is a `SystemFrame`, so a gate matching `SystemFrame` would also eat
  `StartFrame`/`CancelFrame`/`InterruptionFrame` and wedge the call — the gate matches the
  audio class alone, and the test fires SystemFrames at it *while muted* and demands they
  survive. And `on_app_message` is not transport-neutral: SmallWebRTC emits it, the Twilio
  websocket transport does not register it at all, so the handler is registered
  conditionally or every phone call would raise.
- Mute state is session-scoped (it dies with the call), so no `auth.py` migration.
- The control channel is the WebRTC data channel on the peer connection the cockpit already
  opens (the browser creates it, which is the side pipecat listens on). No control-plane
  round trip.

## [1.18.5] - 2026-07-13

The voice stops greeting you over and over, and a task can no longer fail in silence.

### Fixed
- **The voice repeatedly re-greeted mid-call ("Yothere! ... Yothere! ...").** Two causes,
  both structural. The greeting mandate lived in the SYSTEM INSTRUCTION, which is in force on
  *every* turn, so any unprompted turn matched "you speak first, open with Yothere" — and this
  pipeline manufactures unprompted turns constantly (status nudger, fleet watcher, finish
  nudge, false-barge resume). And `_greet` was wired to `on_client_connected` with no
  once-guard, an event that re-fires on every WebRTC reconnect, so an ICE blip literally
  re-queued the greeting. Greeting is now a one-time EVENT: out of the system prompt (which
  now forbids opening a turn with a greeting), a self-expiring kick in both languages, and a
  once-guard so a reconnect never re-greets.
- **Every long answer ended with "Want me to go deeper on anything?"** The server appended it
  to the spoken payload while the prompt ordered "NEVER close with filler like 'anything
  else?'", and the server won, because the model is told to read tool text verbatim. So no
  answer ever landed; each one re-opened the conversation. The pointer stays (there IS more on
  screen), the interrogative is gone. The four speech-pressure loops also each had their own
  cooldown but no shared one, so two landing together read as a monologue; they are now spaced
  apart.
- **A gated task burned its whole 600s wall clock and reported NOTHING.** "Create a public
  HTML page" is blocked by the outward-send deny gate, so every route the worker tried was
  denied; it hunted for another instead of stopping, until the clock killed it. The turn then
  re-stamped `running` (non-terminal), so the deferred watcher polled forever and the user was
  never told anything at all. The worker contract now names the gate UP FRONT (no workaround
  exists; block and ask; blocked-in-30s is a good outcome), and the gate escalates on a repeat
  denial within a turn.
- **A follow-up question spawned a second ten-minute thread.** "What do you mean the html page
  didn't go" was dispatched as new work. The guard against that only armed at 210s, and the
  window in which an impatient human actually asks is the first minute. A status question can
  no longer become a thread at all: the check is deterministic and strictly anaphoric, so
  "what's the status of that" is a follow-up while "what's the status of the SaleUp deal" is
  real work and still dispatches.
- **A timed-out turn reported ZERO cost**, so the cost cap could not stop a hung worker (cost
  arrives only in the harness's final result event, which a killed turn never emits). The
  docstring claimed the opposite. Corrected rather than papered over with a fabricated
  estimate: the caps freeze accounts. `turn_continuations` is the only real bound.
- **`voicecall/config.py`'s selftest failed on live code** — it asserted nudger defaults of
  12.0/45.0 long after they moved to 40.0/90.0, and nobody knew, because no CI job ran it.
  Fixed to track the defaults, and wired into CI.

### Added
- **Calls are recorded.** A 37-minute call left 150 bytes on disk, because the debrief needed
  a local session AND a clean hangup and had neither, so the very call that surfaced all of
  the above was unreviewable. The transcript now appends to `<log_dir>/calls/<ts>-<pid>.jsonl`
  as it is spoken, depending on no session and no clean hangup. Pid-scoped because the hosted
  worker runs several calls per box, and two starting in the same second would otherwise
  interleave two tenants into one file.
- Gemini Live **context-window compression**, so a long call stays one session instead of
  churning through expiry and reconnect.

## [1.18.4] - 2026-07-13

You can pick a finished thread back up by voice, if you ask for it.

### Fixed
- **A spoken follow-up on a FINISHED thread spawned a fresh, context-less thread.** "Change
  step 3 of that plan" reached an agent that had never seen the plan. Two holes, one per
  call boundary: within a call, `follow_up_session` only replied INTO a thread whose
  terminal was `blocked`/`approve_send`, so a `done` one fell through to a fresh enqueue;
  across calls, the session map is in-memory and call-scoped, so after a hang-up the thread
  was unreachable at all. Both now reply into the thread through the same front door the
  cockpit's "Follow up…" button uses (`answer_and_resume` -> `resumed` -> `claude --resume`),
  so the conversation and `plan.json` survive instead of being rebuilt from nothing.

### Added
- **`find_past_threads`** — the voice tool that resolves a finished thread you are referring
  back to ("that pricing plan from Tuesday"). Reopening is never the default: a new request
  still spawns a new thread and never consults history, and the tool RESOLVES a thread
  without reopening it — the model names the match, stops, and waits for your yes before
  anything is replied into. You cannot see the list it matched against, and the wrong match
  edits the wrong plan. A miss stays a miss rather than substituting the most recent thread.
  Threads past the harness's 30-day session-retention window come back `resumable: false`
  (their conversation is gone, so a resume would answer confidently from nothing) and it
  offers a fresh start instead.

## [1.18.3] - 2026-07-13

An FYI reaches the Inbox, and no suite gates nothing any more.

### Fixed
- **An `fyi` never reached the Inbox at all.** `fleet_state` strips the ask off any thread
  that is not blocked/stuck, so a stale approval cannot resurrect as a phantom card with a
  live Send button over content you already discarded (1.13.0). But it stripped EVERY kind,
  and an `fyi` carries no verb: it is a non-blocking notice ("Booked LH2034, no action
  needed") from a thread that KEEPS WORKING. So an fyi could only survive on a *blocked*
  thread — i.e. never in the case it exists for. The feature was dead on arrival and the
  card sat silently in the Working column. Only BLOCKING asks are cleared now; the
  phantom-Send guarantee is unchanged and is asserted beside it.

### Changed
- **Every suite in `tests/` is now wired into CI.** `tools/ci_local.py --orphans` found
  ELEVEN that no job ran, i.e. that gated nothing. Nine were green and are wired (device
  tokens, pairing tokens, login claim, dispatch dedup, task intents, tasks-complete route,
  voice-agent x3). Two had rotted RED unnoticed and are repaired: `test_planner.py` (the
  suite still asserted the pre-lanes `plan_next` contract, and its deadlock regression was
  *vacuous* — it hardcoded a 3-slot pool that had grown to 5, and the default caps made a
  single over-cap thread exhaust the DAILY budget, so it never reached the code it claimed
  to guard) and `tests/e2e/test_cockpit_dom.py` (which was correctly pinning the fyi bug
  above, and which nobody ran).
- **CI costs 58% less** (28.5 -> 11.9 billed min/change): superseded PR runs cancel, pip and
  Chromium are cached (the browser job was re-downloading Chromium every run: 530s -> 152s),
  and the heavy jobs no longer re-run on the merge push that squashes a tree they already
  gated. A Monday full-board run keeps that honest against a moving Chromium/OS image.
- **`tools/ci_local.py`** runs the ENTIRE board offline in ~2m40s — CI's dep-minimal venvs
  across 3.11/3.12/3.13, the voice extra, a real browser, and a real `postgres:16`.

## [1.18.2] - 2026-07-13

Live progress DURING the call, not just after you hang up.

### Fixed
- **A dispatched task's card stayed blank for the whole call and only lit up on hangup.**
  The hosted worker pushes its session cards to the control plane, and the hosted voice
  overlay REPLACES `payload["sessions"]` with them — discarding the thread-activity adoption
  1.18.1 added in `build_payload`. The pushed cards can never carry that themselves: the
  worker cannot see the tenant's threads, which live on the control plane. So during a call
  the card showed nothing, and the moment the call ended the session vanished, the fleet
  workCard stopped being suppressed, and the progress appeared — exactly backwards. The
  overlay now re-adopts against the fleet rows it does have.

## [1.18.1] - 2026-07-13

The live-progress card, but for the tasks you actually dispatch.

### Fixed
- **A hosted task's card was blank while its thread was full of progress.** 1.18.0's card
  enrichment only ever reached *fleet* cards, never anything dispatched from the cockpit —
  which is most real use. Two things met: `QueueBrain` (the hosted harness) declares
  `on_activity`/`on_tool` and never fires them, correctly, because the turn does not run in
  that process at all — the paired laptop leases it and POSTs progress into the THREAD; and
  `app.js` deliberately suppresses the fleet `workCard` for any thread a live session owns
  (else two cards for one task), which is exactly the card that renders thread activity. So
  the tools, args, ms and narration all landed in `activity.json` and were then hidden behind
  the one card that could not see them. The session card now ADOPTS its thread's activity
  when it has none of its own (`overview_state._adopt_thread_activity`) — `activity.json` is
  the single source of truth, so the card reads it rather than teaching QueueBrain to re-emit
  a channel it cannot observe. In-memory events still win when present (a local BobBrain turn
  writes no `activity.json`), and a DONE card adopts nothing, so no stale tool line lands
  under a finished result.

## [1.18.0] - 2026-07-13

The working card can say what the turn is doing.

### Added
- **Live tool detail on the working card.** One tool call is now ONE timeline entry that
  gains its compact args line when the full input exists and its outcome when the tool
  returns: `✓ Bash · 3.6s  pytest tests/test_render.py`, `⟳ Edit …/overview/style.css`.
  The stream already carried all of it (tool_use ids, assistant-frame inputs, user-frame
  tool_results); the worker was parsing and dropping it. `Thread.write_activity` upserts
  by tool id, which also makes the hosted `/progress` path idempotent under keepalive
  re-posts (those used to duplicate timeline entries).
- **A narration line: what the model is SAYING as it works.** The model's actual
  reasoning is server-redacted in `claude -p` (empty `thinking` + signature, verified
  live against 2.1.207 — `MAX_THINKING_TOKENS` raises the budget, not the redaction), so
  its running prose is the honest live proxy. Text deltas, which the streamed turn used
  to parse-and-discard, now feed a debounced tail (2s) rendered as a quiet quote line on
  the card. Display only — the answer is still reconstructed from the final frame.
- **A thinking pulse for the silent stretches.** `system/thinking_tokens` frames carry a
  cumulative size estimate; the card shows `thinking… ~850 tokens` while a reasoning
  block is open and clears it the moment the model says or runs something. The words are
  unrecoverable; the fact and the effort are not.
- Both writer paths carry the new fields: the local runner sink and the BYO leaser's
  `POST /jobs/{lease}/progress`. A tool END outruns the poster's 3s throttle — the ✓/ms
  ride it, and a dropped end would leave a hosted card spinning ⟳ on a finished call.

### Fixed
- **Every fleet work card's tool timeline rendered collapsed** behind an "undefined tool
  calls" summary: `toolsBlock` keyed on `status` (a session-card field) while fleet rows
  carry `state`. Latent since the timeline shipped; visible the moment it had real
  content to show.

## [1.17.0] - 2026-07-13

The hosted workspace pings you again.

### Added
- **The hosted workspace pings you again.** yothere's promise is that you do not babysit the
  fleet: it works, and it comes and gets you when a thread needs a human. That was true for a
  local runner, which fanned out a web push from `runner.tick`. It had NOT been true for the
  hosted workspace since compute moved to the laptop: the box runs the web cockpit only, no
  runner runs on it, and so nothing ever called `decide_pings`. A hosted user who closed the tab
  got NOTHING. The thread blocked, sat in the Inbox, and auto-parked after 24h; they found out by
  remembering to look.

  The tell was that `/push/subscribe` still worked on hosted and dutifully stored each tenant's
  devices in their own home, where nothing ever read them. The cockpit had already given up and
  hidden the bell in hosted mode, calling the opt-in "a dead end". `cloud/notify.py` is what reads
  that file, so the bell is back.

  It reuses `runner.decide_pings` (already pure, and already the owner of coalescing, dedup, the
  30-minute rate limit, quiet hours, and the urgent override) per tenant, so a hosted ping and a
  local ping are the same decision made in two places rather than two decisions that drift.

  It runs on its OWN daemon thread, NOT as a step in the 15s housekeeping tick: `pwa.fan_out` is
  synchronous with a per-endpoint timeout, so one unreachable phone would otherwise stall lease
  recovery for every tenant on the box.

  Two ways this ships looking healthy while doing the wrong thing, both now tested: pushing one
  tenant's asks to another's devices (`pwa.notify_marked` silently falls back to the process state
  dir when `state_dir` is omitted, returning 0 and raising nothing), and re-pinging a settled
  thread on every pass. Suspended accounts are skipped, since `list_tenants()` does not filter them.

## [1.16.0] - 2026-07-13

Running out of steps is not a failed task.

1.15.0 made hitting the per-turn step cap a polite, resumable `stuck` — "reply to continue".
That was still wrong, and raising the cap would not have fixed it: **any number is a cliff
if hitting it stops the task.** The user gets a halted job and a prompt to babysit work they
never asked to supervise, and it only ever fires on the big tasks, which is exactly when
they care.

The thread model is already multi-turn. Every turn does "one turn of work on the plan", the
thread stays `running`, and the runner advances it again with `--resume`, which picks the
same session up where it stopped. So running out of room inside ONE subprocess is an internal
budget, not a failure of the task, and the user should never learn the cap exists.

### Fixed
- **The turn cap no longer stops the task.** It continues on the next turn, with no human in
  the loop.
- **The wall-clock timeout, likewise** — and it now honours a landed result, which is the
  half of the 1.15.0 fix that only ever landed on the non-zero-exit path. A worker that wrote
  `done` + its summary and was then killed at the wall clock had `stuck` stamped over its
  finished answer: the identical bug, on the identical merge store.
- A genuine **crash** still stops. "Ran out of room" and "died" are different things, and
  only the first is safe to retry.
- A continuation never overwrites `what_im_doing` — the worker's own progress narration is
  what the cockpit card shows, and an internal step budget must not replace it.

### Changed
- `CLAUDE_MAX_TURNS` default 30 → 200. It is now a cost/latency knob (each subprocess restart
  re-reads the whole context, so a higher cap means *fewer* restarts), not a cliff.
- New `MAX_TURN_CONTINUATIONS` (20) bounds *consecutive* overruns, so a thread that will never
  converge asks for help instead of grinding. A clean turn resets it: a large task must never
  be shot for being large. Spend was already bounded by the daily and per-thread cost caps.

## [1.15.0] - 2026-07-13

The honesty release. Three bugs, one theme: yothere knew the answer and told you something
else.

A worker would finish a task, write its result, and then the harness would exit non-zero on
the way out — because `claude -p` returns 1 whenever its terminal frame carries `is_error`,
which includes `error_max_turns` and a usage-cap 429, and BOTH of those fire *after* the
model has done its work. The turn engine read only the exit code, stamped `stuck` over the
top, and because `status.json` is a merge store the real answer survived underneath a crash
headline. The cockpit rendered the answer; the phone read out the headline. Only big tasks
ever hit it, because only a big task burns through the 30-turn cap after writing its
summary. That is the whole of "bigger tasks get confused".

The rule now: **the worker's own status.json is the authority on what happened, and the exit
code is not.** A fresh terminal status beats a dirty exit. A thread that genuinely died
mid-work is still stuck, but it says why in words a human can act on instead of leaking an
exit code that is always 1 and therefore says nothing.

### Fixed
- **A non-zero harness exit no longer discards a completed result** (#154). Classify off the
  terminal frame, never the exit code. A usage cap now HOLDS with a retry instead of wedging
  — the existing hold was reachable only from the local runner, so every hosted 429 became a
  permanent `stuck`.
- **A task that outlives its turn is now delivered, not just filed** (#155). Past the 210s
  deferral the voice session used to freeze forever: nothing polled the thread again, and the
  line it says out loud — "I'll report back the moment it lands" — was a promise no code path
  could keep. The result reached the Inbox and never the user's ear. A watcher now puts the
  thread back on the delivery path that already works.
- **Forensics.** A failed hosted turn left nothing behind: the leaser deleted the mirror
  (taking `worker.log` with it) and the one field the failure persisted, `stderr_tail`, is
  provably empty for both common failures. The terminal frame is now persisted, and a failed
  turn's log is kept.
- **`classify_status` no longer throws the worker's summary away** for a stuck thread and
  speaks a canned apology over a finished answer.

### Removed
- `LONG_TASK_LINE`. Dead since #147, and a pre-formatted stall sentence in module scope is a
  loaded gun pointed at `result`.

### Changed
- `CLAUDE_MAX_TURNS` default stays 30, but the cap is now a *named, actionable* failure
  rather than an opaque crash. Operators running big tasks should raise it.
- **`release_gate.py runtime`** (#157) asserts the machines that actually serve users are
  running the release, with `--require-symbol` to prove it by import rather than by version
  string. This exists because #147 shipped correctly to git and PyPI on 07-11 and had still
  never executed on 07-13: every local runtime was two days stale, and the fix was fictional.
  A release is not a release until the processes serving users are running it.

## [1.14.0] - 2026-07-12

The codex release. yothere now runs a thread on the OpenAI Codex CLI as well as on Claude
Code, so a ChatGPT subscription is a complete answer to "what agent runs my work" rather than
a reason you cannot use the product. The harness seam from 1.13.0 held: a second backend was
one line in the registry plus one module, and nothing above `harness/` had to widen to let it
in.

1.13.0 said the real blocker was that "the outward-send deny gate has no codex equivalent",
and `docs/codex-backend.md` put it at a day of code and a week of answering that question.
Both were wrong, and the correction is the most useful thing in this release. Codex has a
Claude-compatible `PreToolUse` deny hook. `worker_hooks/send_deny_gate.py` is reused verbatim,
the same file, with only its registration changed from claude's JSON `--settings` to a
`config.toml` block. A live turn settles it: `git push origin main` is blocked before it runs,
benign shell work passes in the same turn, and a human-approved send grant opens exactly one
send and no more.

What was actually hard is the thing nobody had written down. **Codex silently does not run a
hook it does not trust.** No warning, no error, nothing in the event stream. A turn configured
with our deny gate and full disk access would have been a completely ungated agent while every
log line claimed it was contained. There is no headless way to persist hook trust, so a
contained codex turn passes `--dangerously-bypass-hook-trust`, scoped to a CODEX_HOME that
yothere writes and whose only hook is its own. And because that is a property of codex that
can regress without telling us, the gate is verified rather than trusted: it leaves a
breadcrumb, and a turn that ran shell commands while the gate never fired once is failed
closed and latches `.gate-broken`, which drops every later turn to the read-only sandbox until
`yothere doctor` proves the gate again.

### Added

- **A codex turn backend** (`yothere/harness/codex.py`). Select it with `harness: codex` in
  `brains.yaml` or `YOTHERE_THREAD_HARNESS=codex`. Parity with the claude path: containment,
  the approve-send grant, streamed progress, resume across turns, the cost ledger, the caps.
- **A yothere-owned CODEX_HOME** (`YOTHERE_CODEX_HOME`, default `<state_dir>/codex-home`),
  holding our `config.toml`, our auth, and the rollouts a resume reads. yothere never touches
  the user's `~/.codex`: our deny hooks have no business firing on their interactive codex.
- `yothere doctor` and `yothere init` know codex. They resolve the binary (usually not on
  PATH, since Codex.app keeps it inside the bundle), probe `codex --version`, and warn when
  the codex home has no `auth.json`, naming the one-time `codex login` that fixes it.
- New env: `YOTHERE_CODEX_BIN`, `YOTHERE_CODEX_MODEL` (default `gpt-5.5`),
  `YOTHERE_CODEX_HOME`, `YOTHERE_CODEX_PRICE_TABLE`.

### Fixed

- **The BYO-compute leaser ignored the thread's harness.** `cloud/agent.py` called the turn
  engine with no backend, so it fell through to the default and ran claude on every leased
  turn regardless of what the thread said. With one harness the bug was invisible and nothing
  tested it. A codex user would have watched their local runner honor their brain while every
  hosted turn quietly ran something else.
- **The session id a backend reports was read by nobody.** Claude got away with it because it
  accepts the uuid we mint. Codex mints its own, so without a write-back every codex turn
  would have been turn 1 forever: no resume, no memory of the previous turn.
- **A rate-limited codex thread wedged** instead of holding, because the 429 detector only
  knew claude's frame shape. It reads codex's now, and a codex usage limit fails visibly.
- **Three test suites existed but were wired into nothing**, and two had been red on main for
  days with no way for anyone to notice. They run in CI now. One of them guards the
  outward-send grant.

### Kill switches

Revert without a deploy: set the harness back to `claude` (per thread, via `brains.yaml`, or
`YOTHERE_THREAD_HARNESS=claude` fleet-wide) and nothing in this release executes.
`YOTHERE_WORKER_SEND_POLICY=open` disables containment on either harness, and
`YOTHERE_CODEX_PRICE_TABLE` overrides the cost estimate if the built-in table drifts.

### Known limits

The cost a codex turn reports to the ledger is an **estimate**. Codex reports tokens, not
dollars, so yothere prices them from a table that is a point-in-time snapshot and is not
authoritative. An unknown model bills at the most expensive row, because the conservative
direction for a cap is to over-charge. On a ChatGPT subscription the true marginal cost of a
turn is zero until the plan's usage limit, so read the number as a runaway-loop stopper rather
than an invoice. Codex also has no `--max-turns`, so the chat lane's cap is enforced by
killing the process after N agent actions rather than by the CLI.
## [1.13.2] - 2026-07-11

The trust-surface release. A real coding agent audited the pairing block on a work laptop wired
to live CRM, mail, ad accounts and secrets. It was right to be suspicious, and what it found was
ours to fix:

- **SECURITY.md contradicted the block.** The block cites SECURITY.md as a cross-check, and
  SECURITY.md said outward-send gating "is not part of yothere" — refuting the block's central
  claim while `worker_hooks/send_deny_gate.py` sits in the package and is injected into every
  worker turn. An auditor following our own instruction found our two trust surfaces disagreeing
  on the one claim that matters. SECURITY.md is rewritten around the BYO-compute posture.
- **The block over-claimed containment.** "Outward sends denied until a human approves" reads as
  a guarantee we do not have: the gate stops sends, but reads are uncontained under
  `bypassPermissions`, and a plain `GET` can carry data out (the gate allows GETs by design; its
  own docstring calls itself best-effort, not a sandbox). The block now states that gap itself,
  and offers the scoped-pairing recipe (`YOTHERE_WORK_DIR` + `acceptEdits`) for a machine that
  holds credentials. An agent that finds one claim overstated discounts every other claim.
- **The audit command audited the wrong code.** `pip download yothere` with an unpinned
  interpreter silently resolves a months-old sdist under Python 3.14 (releases cap at `<3.14`),
  so the agent audits code it will never run. The interpreter is now pinned before the download.
- **`yothere --version` did not exist** — the first command an auditor types returned an argparse
  error. Added.
- **Nothing checked whether the machine was already paired**, so a single-use token was minted and
  burned for a laptop that already had a credential. The block now leads with `yothere doctor`,
  and the cockpit's pair panel says when a machine is already connected.

## [1.13.1] - 2026-07-11

Cockpit pairing modal. The pairing block is ~40 lines and the panel is a fixed overlay with an
unbounded card: the block grew the card past the viewport, a fixed overlay clips rather than
scrolls, and the actions below the block went off-screen — Pair and Close became unclickable.
The card is now a bounded flex column (the overlay row is `minmax(0,1fr)`, or the card's
`max-height:100%` resolves against an auto row and bounds nothing), the block scrolls inside
it, and the actions are pinned.

The post-mint button was also inert: a disabled "Token minted ✓" label wearing a button's
clothes. The block exists to be pasted into a coding agent, so minting now hands over a real
**Copy for your agent** clipboard button (with a select-the-text fallback for insecure
contexts) and collapses the block behind a "Show the block" toggle. Same treatment on
`/onboarding`.

## [1.13.0] - 2026-07-11

The latency release. A typed question in a thread's chat box answered ~5x slower than the
same question through a bare `claude -p`, because every turn — including "check my last
message" — paid for the full work contract: read the plan, write status.json, heartbeat.
That contract IS the product for long-running work. It is not what a question needs.

Quick questions now answer at parity with a bare `claude -p` (measured: 2.9s vs 3.0s on a
zero-tool question; 4.8s vs 14.2s on a lookup), and real work keeps the full cockpit
contract byte-for-byte.

### Added

- **Fast-lane chat.** A pure classifier (`thread_model/lane.py`) decides whether a turn is
  someone typing a question or someone asking for work. Chat gets a ~2KB prompt and an
  8-turn cap and answers directly; work is unchanged. Any doubt is work: a voice reply, a
  plan review, a pending send-approval, a first turn, or anything long, multi-line, or in
  the approve/reject vocabulary all stay on the contract. A misclassification degrades soft
  in both directions — the chat prompt carries an escape hatch, and a model that decides the
  message is real work writes its own status.json and the host leaves it alone.
- **Lane-aware capacity.** Chat draws from its own slot pool and is served first, so a
  question never queues behind long work (the turn win is worthless if it is spent waiting).
  Work concurrency 3 → 5; chat gets its own ceiling of 3.
- **Live progress on the work card.** A turn streams its tool activity as it runs, so a
  four-minute turn stops sitting behind "running — no progress reported yet". The new
  `POST /jobs/{lease}/progress` doubles as a **lease heartbeat** — which closes a
  long-standing bug where a turn longer than the 900s lease TTL was re-leased and re-run
  underneath itself.
- **A harness seam** (`yothere/harness/`). The four claude-specific parts of a turn (argv,
  output parsing, containment, session resume) now sit behind a `TurnBackend` protocol;
  everything else in a turn was always harness-neutral and now visibly is. `docs/codex-backend.md`
  specs a second backend without writing one — and finds that the seam holds, while the real
  blocker is that the outward-send deny gate has no codex equivalent.

### Changed

- `/thread/reply`, `/dispatch` and `/jobs/{lease}/result` now WAKE the job queue and the
  `/live` SSE stream instead of leaving them on a fixed 1.5s tick. A reply used to pay a
  tick on the way in and another on the way out; both are now ~0ms.
- Worker turns run with `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` (~0.8s/turn).
- The worker contract no longer calls itself Bob — it defers to the host project's
  CLAUDE.md / AGENTS.md.

### Kill switches

Every piece of this reverts without a deploy: `YOTHERE_WORKER_FAST_LANE=0` (fast lane, per
tenant or fleet-wide), `YOTHERE_MAX_CONCURRENCY=3` + `YOTHERE_CHAT_CONCURRENCY=0` (capacity),
`YOTHERE_STREAM_TURNS=0` (streaming).

## [1.12.0] - 2026-07-11

The cost-cap release. A voice-dispatched thread that crossed the per-thread budget cap
used to freeze silently and become permanently unanswerable; it now returns to the Inbox
as an answerable card, "raise the cap" actually raises it, and Discard closes the thread
without needing the very worker turn the cap was blocking. Also in this release:
transactional email sends from production again (Cloudflare was 403ing every Resend
call), over-cap threads no longer starve a tenant's whole queue, replies come back
noticeably faster, and the test suite stopped writing real threads into a live fleet.

### Changed
- **A reply no longer waits on two fixed ticks before anything happens.** A web-chat reply
  sat through a 1.5s tick before `/jobs/next` even noticed the thread was resumed, and
  another before `/live` told the browser the answer existed: dead time on both ends of
  every wait, on top of the turn itself. Both loops are now woken on the edge.
  `cloud/voice.py` grows `live_wake`/`signal_live` (the third use of the same pattern
  `jobs_wake`/`signal_jobs` already had), and the routes that change state signal it:
  `/thread/{id}/reply` (jobs + live where a turn follows, live alone on the no-turn
  branches, since a discard has no worker to wake), `/dispatch` (both), and
  `/jobs/{lease}/result` (live, because that instant is when the answer exists; jobs,
  because closing the lease frees a slot). Skew-safe by construction: the 1.5s tick
  survives as the wait's timeout, the payload is rebuilt from the store on every pass, and
  a route that never signals behaves exactly as it does today, so the server can deploy
  ahead of the fleet and a missed or coalesced signal costs one tick, never an update. The
  worker turn also sets `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`: a headless turn has
  nobody watching the harness's telemetry chatter, and every round-trip of it is latency a
  human waits on (2.97s vs 3.79s on a trivial turn, n=3, faster in every rep).

### Added
- **Resend delivery webhooks -> a first-party `email_events` table (analytics S2).**
  `POST /webhooks/resend` verifies svix's HMAC signature (stdlib, no `svix` dependency;
  ±5min replay window, constant-time compare), then records each `email.sent /
  delivered / bounced / complained / opened / clicked` event keyed by the svix message
  id so retries are idempotent. Metadata only — a delivery event never stores message
  content. `yothere admin email [--json]` reports 7d/30d counts, delivery rate, and a
  bounces/complaints tail. The endpoint is inert (503) until `RESEND_WEBHOOK_SECRET` is
  set; a store-write failure returns 500 so svix redelivers rather than dropping.

### Fixed
- **A cost-capped thread no longer freezes silently, and Discard never needs a turn.**
  A voice-dispatched LinkedIn thread ran two real turns (~$2.60 of MCP calls, vault reads
  and profile lookups), crossed the $1.50 per-thread cap, and became permanently
  unanswerable: the hosted planner dropped it into `held_ids` and returned `None` without
  writing any status. Every later reply, Discard included, 200'd at `/thread/{id}/reply`,
  flipped the thread to `resumed`, and then died in the planner. The card sat in the Inbox
  forever with no explanation. Four defects, one incident, all four now locked by
  `tests/test_cost_cap_freeze.py` (each is red on the pre-fix code):
  1. `per_thread_cap_usd` was $1.50, below the cost of a single real agentic turn, so
     threads capped almost immediately. Raised to $10 and made env-tunable
     (`YOTHERE_PER_THREAD_CAP_USD`), mirroring the daily cap. It was the one cap with no
     runtime knob: a host could raise its fleet budget and still freeze every thread.
  2. The hosted hold was silent. The off-Fly runner has always written a blocked-cap card
     ("Continue, raise the cap, or stop?"); the hosted planner wrote nothing. It now writes
     the identical card, idempotently, so the thread comes back to the Inbox answerable
     instead of vanishing.
  3. "Raise the cap" was a dead end. The runner asked the question and nothing read the
     answer, so the next tick re-held the thread on the same unchanged cap. An answer now
     lifts that thread's own ceiling via a `cost_cap_usd` meta override
     (`store.raise_thread_cap`, read by `runner.thread_cap_usd`).
  4. Discard required a worker turn to interpret the words "do not send", so cancelling was
     impossible precisely when the thread could not be leased. A cancel must never depend on
     the thing being cancelled. It is now a pure state transition
     (`store.resolve_ask_without_turn`): resolve the ask, close the thread, no lease, no cost.
- **Over-cap threads no longer consume capacity slots (tenant deadlock).** `plan_next`
  applied the per-thread budget cap only after `plan_tick` had allocated capacity slots by
  rank, so threads over the cap could win every slot on every `/jobs/next` poll and then be
  dropped post-hoc, returning `None` forever. The tenant's whole queue starved behind
  budget-dead threads. Live incident 2026-07-10 (tenant `phiwenger`): three resumed drafts
  at $1.51/$1.81/$1.56 against the $1.50 cap outranked everything on the staleness bonus,
  occupied all 3 slots on every poll, and no job was leased for hours. The over-cap set is
  now computed up front and fed to `plan_tick` as `held_ids`, which are excluded before slot
  allocation, so capacity always goes to affordable threads.
- **Transactional email actually sends from production.** Every Resend send from the
  Fly box failed `HTTP 403 "error code: 1010"`: Cloudflare's edge in front of
  `api.resend.com` bans the default `Python-urllib/x.y` User-Agent — the exact trap
  `cloud/turn.py` documented on 2026-07-06. `cloud/email.py` now sends the same
  non-default UA on every request (`_post_once`), and the payload-shape test pins it
  so it can't regress. Verified live from the Fly box: identical payload 403s without
  the header, 200 with it.
- **The postgres CI job runs again.** `tests/test_auth_pg.py` is dual-run: CI invokes it
  bare (`python tests/test_auth_pg.py`) in a venv that installs only `.[postgres]`, and it
  is also collected under pytest. An unguarded module-level `import pytest` meant every
  postgres job died at import with `ModuleNotFoundError` before a single assertion, leaving
  main red for a day and every open PR inheriting the red X. The pytest import is now
  guarded the way `tests/test_worker_policy.py` already guarded it. Separately, five
  pure-pytest files were never invoked by anything in `ci.yml` and had been passing by never
  executing; they are now wired into the suite.
- **The test suite no longer writes into a live fleet.** Several voice tests called
  `dispatch_session` with no injected threads root, which fell back to the real
  `~/.yothere/threads` on a machine that also hosts a running yothere runner. The runner then
  claimed, executed and billed those stub threads (observed: 126 of them, and a day's budget
  gone). A `tests/conftest.py` hook now re-points every in-process fleet-store sink at a
  throwaway home before any test runs. In-process only, on purpose: it does not set
  `os.environ`, because subprocess tests pin their own home and an ambient `YOTHERE_HOME`
  would beat that pin.
- **`pytest --ignore=tests/e2e` collects and skips cleanly.** Four red groups, all
  pre-existing and none of them real product defects: `test_init` leaked an ambient
  `YOTHERE_HOME` into its subprocess env, two script-style files errored under collection (and
  would have false-greened if handed fixtures), and four Postgres-only tests bypassed the
  `DATABASE_URL` gate their `main()` applies. Each is now isolated, ignored, or skip-marked.

## [1.11.1] - 2026-07-10

Voice re-test fixes from Phil's 1.10.0 live call: a single spoken request now stays
one dispatch, the "what's cooking" status is far less chatty, and the agent finishes
what it's saying before it moves on. All three are prompt/config tuning — no new
surface, no schema change.

### Changed
- **One actionable request = one dispatch.** The voice SYSTEM_PROMPT's "each separate
  request is its own dispatch_session call" line was over-firing: "send a LinkedIn
  message to Oscar about X" fanned into two dispatches (the send + the message
  content). The contract now spells out that a single action and everything it needs
  (one recipient, one message, its content and context) is ONE call — don't split
  composing a message from sending it, or a lookup from the action it feeds. This is
  the opposite tail of the 1.10.0 dedup-collapse fix; dedup itself is untouched, and
  `tests/test_dispatch_dedup.py` still guards the collapse direction (25 passed).
- **Status nudger is quieter.** `nudger_idle_floor_secs` 12 → 40 and
  `nudger_cooldown_secs` 45 → 90 (`voicecall/config.py`, env-tunable via
  `YOTHERE_VOICE_NUDGER_IDLE_SECS` / `_COOLDOWN_SECS`; module fallbacks in
  `voicecall/pipeline.py` mirrored). The dead-air "still working on … these are ready"
  line now waits for a real ~40s silence and won't re-list for a good while after.
  The prompt's BE PROACTIVE clause is tightened too: at most one short status line,
  only when a `[status]` note arrives, never recited as a reflex.
- **Never abandon the answer you're giving.** Added an explicit PACING rule: once the
  agent starts delivering a result or explanation it finishes completely before moving
  on — it no longer cuts itself off mid-answer to announce a newly-ready task or jump
  to a different one. A `[a background task just finished]` note that lands mid-answer
  waits until the current answer is done (the finish-announce inject already used the
  gated hold/drop `_inject`, so this closes the model-behavior side).

## [1.11.0] - 2026-07-10

The cockpit UX 4-pack: Connect gives instant boot feedback that clears exactly when
the agent speaks (no more silent ~18s hosted cold start), the greeting opens with the
brand word, and dead chrome (the disabled mic button, the hosted push bell that could
never deliver) is gone or honestly gated.

### Added
- **Connect boot feedback on the cockpit.** Clicking Connect now shows an instant,
  pulsing staged status ("Requesting microphone…" → "Connecting…" → "Waiting for
  yothere to speak…") on all three connect paths (direct, laptop broker, hosted
  Daily), set synchronously in the click handler so it never lags the ~1.5s SSE
  repaint. It clears on the FIRST AUDIBLE bot audio (WebAudio analyser RMS at a
  0.02 floor, ~6x the measured WebRTC comfort noise; dismiss-on-track fallback
  when WebAudio is absent) and on every error/hangup path, including the hosted
  45s worker watchdog — the ~18s hosted cold start is no longer a silent
  "connected" mystery. The direct connect path also gained the `connecting`
  double-click guard the brokered paths already had.

### Changed
- **The greeting opens with "Yothere".** The greeting kick and the SYSTEM_PROMPT
  now instruct the model to start its first sentence with the literal word
  "Yothere" (e.g. "Yothere! What can I do for you?"); instruction-level, and the
  headless eval drivers share the same constant instead of drifting copies.

### Fixed
- **Dead mic button removed** from the command bar ("Voice input coming soon" was
  permanently disabled and wired to nothing); the text input + Dispatch stay.
- **Push bell hidden in hosted mode.** Web pushes are only ever fanned out by the
  LOCAL runner, so the hosted opt-in was a dead end; the bell now unhides only
  once the /live frame confirms local mode (support probe + mode signal both
  funnel through one idempotent sync).

## [1.10.0] - 2026-07-10

Everything the 1.9.0 live phone re-test surfaced, fixed the same afternoon: multi-task
voice calls no longer collapse onto one thread, the WhatsApp bridge health probe no
longer consumes the send approval, every inbox card is dismissable, and the voice
session can now read AND act on the open inbox ("walk my inbox").

### Added
- **Voice sessions read AND act on the open Inbox** (the "walk my inbox point by
  point" loop). New shared module `yothere.inbox`: `snapshot()` reads the same
  blocked-with-a-blocking-ask fleet rows the cockpit Inbox shows (newest first,
  true count + capped speech-ready items, drafts trimmed for read-back);
  `answer_item()` acts on ONE item through the EXISTING code paths — reply via
  the shared `store.answer_and_resume` front door (approve_send replies are
  classified server-side with the narrow fail-closed detector: only a tight
  spoken approval mints the send grant, a discard is never an approval, unclear
  relays value-less), dismiss via the archive primitive (refusing a live
  running/resumed thread, the 409 rule). Two new voice tools on the live call:
  `read_inbox` (sanctioned direct read, one-item-at-a-time walk contract in the
  SYSTEM_PROMPT — the model must wait for the user's verdict per item and can
  never answer or dismiss an item he hasn't addressed) and `answer_inbox_item`
  (reply/dismiss with honest spoken failures). The call greeting now opens with
  the open-item COUNT when items are waiting (count only, never a listing).
  Hosted path: worker-token routes `GET /voice/calls/{id}/inbox` +
  `POST /voice/calls/{id}/inbox/{thread_id}/answer` (tenant-scoped exactly like
  the tasks read), with fail-soft media-worker clients `fetch_call_inbox` /
  `post_call_inbox_answer` injected as the SessionManager `inbox_reader` /
  `inbox_actor` seams — a dead control plane degrades to an honest "inbox
  unavailable", never a crashed call.

### Fixed
- **Worker send gate: `whatsapp-send send.py --check` is a bridge health probe, not a send.** The gate now exempts the bare probe at the same token level as the WhatsApp CLI match (so `bash -c` wrapping behaves identically): allowed without a grant and never consumes the single-use grant. Fail-closed scope — real-send argv, non-`whatsapp-send` paths, `send_message*`, notify.py/send_draft.py, and the `/api/send` endpoint all keep the ordinary deny. (2026-07-10 live incident: a pre-draft `--check` consumed the grant, so the approved real send that followed was denied.)
- **Every inbox card is dismissable.** `question`, `fyi`, and unknown ask kinds on the
  cockpit /overview now carry the pastCard Dismiss (archive) affordance; approve kinds
  keep their own resolve-and-clear verbs. DOM + source pins updated.
- **Dispatch dedup no longer collapses different voice tasks for a task-source-configured tenant.** The #115 enqueue-time `[TASK-SOURCE OVERRIDE]` stamp (identical on every dispatch) ran BEFORE dedup + the `meta.utterance` stamp, so dissimilar tasks scored as duplicates and deduped onto one thread (2026-07-10 incident). The enqueue route now dedups/stamps on the RAW task (`spawn_voice_thread(..., utterance=)`) while only the thread body carries the override, and `find_similar_active` strips a trailing override block from both comparison sides to heal threads already polluted on disk.

## [1.9.0] - 2026-07-10

Hardens the hosted voice session against everything the 2026-07-09 live dogfood call
surfaced: the assistant no longer recites its own instructions, browser barge-in
actually interrupts, a spoken send-approval lands on the draft it approves, and
"what are my open tasks" reads the tenant's discovered task source instead of the
legacy vault files. Also carries the hosted account settings page and self-serve
account deletion.

### Added
- **Hosted voice reads the tenant task cache.** New worker-token route
  `GET /voice/calls/{call_id}/tasks` serves the control-plane task cache (configured,
  staleness, tasks, preamble override) and kicks a deduped `refresh` task job when the
  cache is stale; the hosted media worker's `read_tasks` answers from it (fail-soft,
  short memo) instead of degrading to a dispatch on a box with no local cache;
  voice-dispatched turns get the `[TASK-SOURCE OVERRIDE]` block stamped at enqueue, and
  `SESSION_TASK_PREAMBLE` says the override wins over the legacy vault files. A list
  older than 24h states its age before being read. (#115)
- **Per-transport VAD profiles.** WebRTC sessions (browser + hosted SFU) default to
  `START_SENSITIVITY_HIGH` + 900ms silence so barge-in works over an echo-cancelled
  mic; Twilio telephony keeps the outdoor-tolerant LOW/1200 profile. Explicit
  `YOTHERE_VOICE_VAD_*` env values win over either profile and are now honored by the
  hosted worker and local host configs too. (#113)

- **Hosted account settings page.** The cockpit gear now opens `/settings` in hosted
  mode (it stays honestly inert in local mode, where `yothere passwd` is the path):
  change password (current password required, verified through the same lockout as
  `/login`; every other session is signed out, the acting one survives), a Machines
  section listing paired devices with one-click revoke (on the existing
  `/device/list` + `/device/revoke` routes), "Sign out everywhere else", and a log
  out button. New store primitive `revoke_other_sessions` (kill all sessions except
  the acting one).
- **Self-serve account deletion.** The settings danger zone deletes the account for
  real: exact confirm phrase + the account password (wrong guesses hit the same
  lockout as `/login`), then `AuthStore.delete_account` cascades every user-keyed
  row in one transaction (sessions, device tokens, login-attempt history, bound
  invites — plus pairing/email-token tables whenever the schema has them), the
  queue + voice stores purge the tenant's rows (`purge_tenant`), and the tenant
  home is archived by atomic rename (`.deleted-<tenant>-<ts>`, slug-validated,
  never an rm -rf) for a grace window instead of destroyed.

### Fixed
- **Voice meta-question guard.** The voice orchestrator's instructions, internal rules,
  and tool list are confidential: asked about them (in any language or framing), it
  gives a one-line deflection and pivots to capabilities instead of reciting the
  prompt. Dispatched-agent replies carry the same non-reveal rule. (#113)
- **False-barge auto-resume no longer talks over the user.** The resume nudge fires
  only when the floor stayed quiet for the whole watch window, so a real barge-in whose
  transcript final arrives late is not steamrolled; the telephony echo-recovery path
  (post-interrupt grace) still resumes. (#113)
- **Stray spoken approvals route to the draft they approve.** An approval-shaped
  utterance that arrives as a NEW dispatch ("the user approved send it now") is routed
  into the tenant's single fresh `approve_send`-blocked thread via the shared
  answer_and_resume front-door instead of spawning a context-less thread the send gate
  then blocks. The `/reply` value-less fallback classifies with a deliberately narrow
  whole-utterance detector: partial approvals ("yes but change the greeting"),
  questions ("did you send it"), and deferrals ("send it after lunch") stay value-less
  and re-ask instead of sending the old draft. (#114)
- **WhatsApp-shaped send CLIs are contained by the worker send gate.** Tokens naming a
  whatsapp send script or the bridge `/api/send` endpoint (including cd-split and
  `python -c` forms) deny without a grant, same as the Gmail/Telegram senders;
  renamed neutral paths remain out of scope per the gate's best-effort threat
  model. (#114)

Deferred, explicitly out of v1:
- Brain-side token lifecycle: issue / rotate / revoke / scope. (The **pairing-token**
  half, single-use issue + atomic claim + expiry, shipped in 1.8.0; rotate/scope for
  long-lived device + brain credentials remain deferred.)
- Done ✓ on legacy vault-fallback task rows (no learned source to write back to;
  the server 409s them and the cockpit hides the affordance).

## [1.8.0] - 2026-07-10

Machine pairing is now trustworthy by construction: the copy-paste block a coding agent
receives makes claims it can actually check, the pairing token is genuinely single-use,
and the unattended-worker consent is enforced before the leaser starts. The release
also carries the beta feedback + NSM loops, transactional email (Resend), and the
smart task-interaction spine (triage walkthrough, read intents, done write-back).

### Added
- **Single-use pairing-token exchange.** "Pair a machine" now mints a short-lived
  **pairing** token (60-min expiry, sha256-at-rest, new `pairing_tokens` table) instead of
  a directly-usable device token. `yothere login` exchanges it at the new public, per-IP
  throttled `POST /device/claim` for a fresh per-device credential; the pairing token is
  atomically consumed at claim (a second claim, an expired-unclaimed token, or an unknown
  token get an honest 410/401). Existing paired devices are untouched: `device_tokens` /
  `resolve_device_token` are unchanged, so this needs zero migration.
- **Consent-ack enforcement on BYO service start.** `yothere service` now refuses to start
  the leaser when the resolved worker posture is `bypassPermissions` and the unattended-
  tool-use consent is unrecorded: it prints the consent text and exits 3 with the exact
  non-interactive ack (`export YOTHERE_BYPASS_PERMISSIONS_ACK=1`, which also works from an
  env file / launchd plist; an onboarding-recorded ack is honoured too). Existing installs
  are prompted once at upgrade.

- **Feedback intake (loop 2).** A 💬 button in the cockpit header opens a lightweight
  dialog that posts to a new `POST /feedback` route; `yothere feedback "<text>"` does the
  same from a paired machine (via the device token) and falls back to a local record when
  unpaired. Everything lands in a `feedback_events` table, read back with `yothere admin
  feedback [--json] [-n N]` for the weekly triage. Empty text is rejected, text is capped
  at 4000 chars, and the route rides the same auth gate as every data route (cookie from
  the cockpit, device token from the CLI).
- **North-star metric instrumentation (loop 1).** Every dispatch and every thread reply
  now records a raw "steer" into a `usage_events` table. `yothere admin nsm [--json]`
  reports agent-threads a user actively steers per week (per-tenant + median), active
  tenants, and total steers for the trailing 7 days vs the prior 7, plus the cumulative
  day-2 return. `yothere admin funnel` gains `--json`. Raw counts only — no rates at beta
  n. Both tables sit behind the same SQLite/Postgres seam and fail-open emit convention as
  the activation funnel.
- **Transactional email via Resend (hosted-only).** One provider secret
  (`RESEND_API_KEY`) arms the channel; unset — every local install — every send is
  a logged no-op, so off-mode stays byte-identical. stdlib-urllib transport
  (`cloud/email.py`, one retry on 5xx/429/network, failures land in
  `yothere admin errors` as `email/<purpose>` rows) + inline html/text templates
  (`cloud/email_templates.py`), sent from `no-reply@mail.yothere.ai` with
  reply-to `hey@yothere.ai`. Four flows:
  - **Self-serve password reset** (`/forgot` + `/reset`): single-use 45-minute
    tokens stored as sha256 (the sessions model), an identical 200 notice on every
    `/forgot` branch (no account-existence oracle), per-IP + per-target-email
    throttle, GET peeks without consuming (prefetch-safe), and a successful reset
    mirrors `yothere passwd` (lockout lifted, every live session revoked). Before
    this, a forgotten hosted password was operator-CLI-only.
  - **Signup welcome + soft verification**: a fire-and-forget welcome email with
    an optional `/verify` link; `users.email_verified` gates nothing.
  - **Invite delivery**: `yothere invite create --email <addr> --send [--note]`
    emails the code (best-effort AFTER the mint; the code always prints).
  - **Budget-cap notice**: the cost-blocked lease seam (`plan_next`) and the
    voice-cap 429s email the tenant at most once per tenant per day (atomic
    milestone dedupe shared across sources).
- **Task-by-task triage walkthrough.** "Go task by task →" on the tasks strip opens a
  stepper (one card per task, most-urgent-first from the cache): Delegate exits into
  the prefilled command bar, Done ✓ fires the direct write-back, Skip advances. Pure
  frontend state over the existing SSE frame — a payload reorder can't shift the
  pointer (key-snapshot), and a task completed elsewhere auto-advances.
- **Deterministic command-bar task intents.** "what's my most important task",
  "what's next", "go task by task" typed into the dispatch bar answer locally from
  the cached task strip (zero brain turns, nothing spawned). Exact-match on a
  normalized utterance (`voice.task_read_intent`), mirrored in the cockpit with a
  parity test; `/dispatch` refuses to spawn on a read intent as the backstop
  (`{ok:false, intent}`), so a misrouted read never mints a junk thread.
- **`POST /tasks/complete` — mark a task done at its source without delegating.**
  Reuses the delegate-accept write-back machinery (factored `_fire_completion`):
  hosted → a deduped `complete` task job for the paired laptop; off mode →
  `start_completion_async`. One brain turn per call, so hosted tenants get a
  10-per-5-min sliding window (429). A failed write-back now records
  `tasks_meta.error` (both modes), which un-mutes the optimistically muted chip.
- **`read_tasks` voice fast path.** New Gemini tool returning the local task-cache
  snapshot (capped at 8 spoken rows, true count + staleness carried) so "what's my
  most important task" on a call answers in seconds instead of dispatching a full
  background session. Read-only by contract; task ACTIONS still route through
  `dispatch_session`. Injectable `tasks_reader` seam on `SessionManager` for the
  hosted-media worker.

### Changed
- **Pairing block v3 (pause-and-confirm as the designed agent flow).** The copy-paste block
  is rewritten so "verify, ask your human one question, proceed" is the intended happy path:
  a STEP 0 confirmation question, sdist-audit instructions (`pip download yothere --no-deps
  --no-binary :all:`) naming the three-file risk surface, and an honest token lifecycle
  (single-use, 60-min expiry, sha256-only). Dropped the old "nothing is urgent" /
  "no sudo" / outbound-only rebuttal list; the block now leads with checkable facts.

- Voice `SYSTEM_PROMPT`: task-list READS are now the second sanctioned exception to
  dispatch-immediately (alongside dictation); task changes and details stay
  dispatched. `list_sessions` guidance routes open-task questions to `read_tasks`.

### Breaking
- **Pairing flow only.** The PyPI publish must precede/accompany the control-plane deploy:
  an old CLI pairing against a 1.8.0 server saves the raw pairing token and its `/jobs/*`
  polls get a **401 with an upgrade hint** ("pairing token must be exchanged at login;
  upgrade: pipx upgrade yothere"). Already-paired devices and their device tokens keep
  working; only the pair-then-login handshake changed.

## [1.7.0] — 2026-07-09

The voice send-approval spine: a spoken "send it" on a call now reaches the
already-blocked thread and mints the outward-send grant, so "speak a send, confirm
it, it fires" works end to end for every tenant. Plus the BYO leaser is finally
installable as an always-on unit, and a queued job can be cancelled from the cockpit.

### Added
- **Spoken send-approval, end to end (defects 3a/3b/3c).** On a voice call, a worker
  drafts an outbound message, the call reads the draft back, and a spoken approval mints
  the single-turn send grant via the existing gate channel — no fresh, context-less
  thread. Three pieces:
  - `ask.classify_send_reply(text) -> "send" | "discard" | "unclear"` — a pure,
    stdlib, **fail-closed** classifier (mixed signals / hedges / unrecognized → `unclear`;
    `send` only on an unambiguous, hedge-free approval), shared by the mic path and the
    hosted call path.
  - Draft read-back: `queue_brain.classify_status` speaks the question + the draft
    (voice-capped ~600 chars; the cockpit card keeps the 4000 cap), and `SessionManager`
    tracks a session that is `awaiting="send_approval"` so `check_results` tells the model
    to read the draft back and relay the user's exact words.
  - New call-scoped worker route `POST /voice/calls/{call_id}/threads/{tid}/reply`;
    `follow_up_session` server-classifies a blocked send-approval follow-up and replies
    into the SAME thread (`QueueBrain.stream_reply`), so `value=="send"` records the
    approval that becomes the grant. The server classifies; the model never decides.
- **Install the BYO leaser as an always-on unit (defect 1).** `yothere service
  [run|install|uninstall|status]` — `install` writes a launchd `KeepAlive` /
  systemd `Restart=always` unit that runs the `/jobs` leaser, with the claude bin dir
  baked into the wrapper PATH (launchd's bare PATH was the #1 silent-failure mode) and
  install-time guards (paired? claude on PATH? work dir set?). Bare `yothere service`
  keeps today's foreground loop.
- **Cancel a queued job from the cockpit (defect 5).** `POST /thread/{id}/cancel` parks
  a queued thread (running-vocab, no live lease) so it can be archived; 409 on an
  actively-leased turn (no mid-turn remote kill in v1); idempotent on parked/terminal.
  A Cancel chip on queued workCards + operator `yothere admin jobs|cancel`.

### Changed
- **Send gate on BobBrain voice turns (defect 4).** bob-harness voice workers spawned
  `claude -p --permission-mode bypassPermissions` with **no** send-deny gate — the only
  ungated path in the fleet. `BobBrain` now injects the same gate the runner/queue paths
  use (mirrors `resume._containment_for`): default gated; `YOTHERE_WORKER_SEND_POLICY=open`
  opts out; a `GateUnavailable` failure **fails closed** by dropping `--permission-mode`
  rather than running bypass with no gate.
- `cloud/voice_worker_call.py` logs the image build SHA + package version at spawn (to
  catch worker-image lag).

## [1.6.0] — 2026-07-09

Cockpit UX overhaul (BYO task discovery + task lifecycle) and hosted-control-plane
performance. First wheel to carry the BYO leaser's task-source path and PR1's
completion-confirmation prompt/nudge half to paired laptops.

### Added
- **BYO task discovery via the jobs queue.** "From your tasks" is now real on the
  hosted control plane. Task discovery/refresh/write-back is COMPUTE the control
  plane can't run, so it becomes a first-class **task job** leased out to the
  tenant's paired laptop over the same `/jobs` channel the turn queue uses (not a
  synthetic thread). New `task_jobs` table (SQLite + Postgres) with same-kind
  dedupe, atomic lease, and expire-requeue; `GET /jobs/next?caps=tasks` capability
  negotiation (an old wheel sends no caps and never sees a task job — back-compat
  both directions); device-token `POST /jobs/task/{lease}/result`; the leaser
  (`yothere service`) runs the turns locally and posts the result. The cockpit
  chip gains a real Connect + honest "queued — waiting for your machine" /
  "discovering" / Retry states.
- **Task lifecycle + completion confirmation (PR1).** Finished threads land in Past
  (no done→Inbox bounce); accept-without-a-turn (`POST /thread/{id}/accept`) +
  `/archive`; the agent's done summary must name the executed outcome; dispatch +
  render dedup. The prompt/nudge half (thread-system completion-confirmation +
  resume send-approval block) reaches BYO laptops at THIS release.

### Changed
- **Hosted control-plane performance.** A shared psycopg connection pool across the
  Auth/Job/Voice stores (#80), and the `/live` + `/jobs/next` scan work moved off
  the event loop under a concurrency cap so one tenant's poll never freezes the
  loop for every other tenant (#83). Lease + voice-call + task-job expiry is now
  owned by the central supervisor tick.

## [1.5.8] — 2026-07-08

Agent-trust pairing release. Rewrites the Connect-a-machine block so a security-minded
coding agent (or human) can verify what it is being asked to run, and finishes the
user-visible relay→yothere rename.

### Added
- **Hosted media plane (voice 2c) — built, not yet deployed.** A Daily-SFU voice
  worker plus the control-plane wiring for it (#65), so a hosted call's media will
  no longer require the tenant's paired laptop. Merged to `main` but not cut over:
  the laptop-hybrid broker (1.5.4's A0-A2) remains the live default until the
  hosted media plane is rolled out. Hardened pre-rollout: hosted transcript overlay
  + queue-and-warn when no compute is paired (#71), ams region pin, dispatch
  reconnect, rich hosted session cards, `/jobs` event-wake (#72), the worker's
  Gemini Live model pinned to the proven laptop model (#73), and a concurrent
  leaser + `approve_send` human-approval grant channel + honest queue cards +
  long-task voice line (#74).
- **BYO-aware `yothere doctor`.** A machine running the service via the hosted
  BYO-compute loop now reports an explicit PASS (with a
  `python -m yothere.service install` hint) instead of a misleading warning.

### Changed
- **Agent-trust pairing block (rewrite).** The pairing instructions the cockpit
  emits are now built from a single `_PAIR_CMDS_JS` template with explicit trust
  anchors: provenance links, a pinned-python install line, an accurate scope
  statement of what the token can and cannot do, and no urgency framing. This
  replaces the 1.5.7 block that a security-posture agent correctly refused to run
  as spear-phishing-shaped.
- **relay→yothere rename, user-visible surfaces.** `YOTHERE_*` is the canonical
  env namespace (legacy `RELAY_*` still resolves as a deprecated alias),
  service labels are `com.yothere.*`, log/CLI strings no longer say "relay", and
  `yothere doctor --bundle` writes `yothere-doctor-bundle.tar.gz`.

### Removed
- **The `relay` console script.** The CLI is `yothere` (and `yothere-mcp`); CI and
  release smoke tests now exercise `yothere --help`.
- **Beta docs: GitHub-issues bug path and public source-install block.** Bug
  reports are email-only (`hey@yothere.ai` + the doctor bundle); source access is
  by ask while the repo is private.

## [1.5.7] — 2026-07-07

Install-robustness + cross-NAT voice release. Makes a fresh customer install deterministic
instead of a resolver lottery, and unblocks the cross-NAT voice call.

### Added
- **`yothere doctor` CLI verb** with three new install checks: `python-range` (fails on
  Python 3.14+), `deps` (av/opencv version-drift guard for the pinned pair), and
  `webrtc-loopback` (aiortc offer/answer SDP self-consistency), plus a `skip` state so a
  core install reports honestly.
- **Required headless-Chromium CI gate** (`voice-browser`): drives the real `/overview`
  Connect flow and asserts no `Invalid SDP` reject and that inbound audio streams. Backed
  by an env-gated (`RELAY_VOICE_LOOPBACK`) Gemini-less echo pipeline so it needs no API key.

### Changed
- **Pinned the WebRTC stack.** `requires-python` capped to `>=3.11,<3.14`; `[voice]` now
  pins `pipecat-ai==1.4.0`, `aiortc==1.14.0`, and `av==16.1.0`/`opencv-python==4.13.0.92`
  as a known-good pair (a fresh unpinned resolve on 3.14 produced browser-rejected answer
  SDP). New `[e2e]` extra for Playwright. `requirements.lock` gates `audioop-lts` to
  Python >=3.13 so the lock installs on 3.12.
- **Agent-ready pairing instructions.** The Connect-a-machine panel (and onboarding page)
  now emit a copy-paste block a user can hand to their coding agent verbatim: open-source
  provenance (repo + PyPI links), an accurate BYO-compute trust model, the Python 3.11-3.13
  requirement, and one-time-token hygiene.

### Fixed
- **CF TURN 401 on cross-NAT calls.** Cloudflare Realtime TURN rejects channel-binds to
  non-globally-routable peers with a fatal bare 401; the laptop answerer now strips the
  browser's non-global `a=candidate:` lines from the offer (so it only binds to public
  srflx/relay candidates), and the brokered browser path defaults to relay-only when a TURN
  server is present (`?ice=all` escape hatch).

## [1.5.6] — 2026-07-06

BYO-hardening release: preflight, control-plane safety for any user, and self-serve
device management.

### Added
- **`yothere devices list|revoke`** — self-serve device management from any paired
  machine: list every paired machine (with last-seen) and de-authorise a lost or old
  one by `device_id` (its polls then 401). (#58)
- **Control-plane safety for any user**: the tenant supervisor tick runs in a daemon
  thread (not a FastAPI startup event), a harness gate clamps hosted tenants, and
  per-tenant rate limits + lease scoping protect the job queue. (#56, #57)

### Fixed
- **BYO preflight**: `yothere service` verifies the `claude` CLI is present and
  runnable *before* it starts polling for jobs, instead of failing per-turn. (#55)

### Changed
- **Finished the RELAY→YOTHERE rename**: per-tenant homes migrate
  `.relay-<tenant>` → `.yothere-<tenant>` automatically, and the remaining docs and
  internals follow the `yothere` naming. (#59)

## [1.5.5] — 2026-07-06

Hosted-voice phases A3-A5 on top of 1.5.4's laptop-hybrid broker.

### Added
- **A3 — cross-NAT TURN** via Cloudflare Realtime, so brokered calls connect when
  browser and laptop sit behind different NATs. (#51)
- **A5 — live transcript push-up**: the hosted cockpit voice panel streams the call
  transcript live. (#52)
- **A4 (staged) — hosted billing**: ephemeral Gemini tokens + per-tenant caps, staged
  behind config until armed. (#53)

## [1.5.4] — 2026-07-06

### Added
- **Hosted voice (laptop-hybrid, A0-A2).** Hosted tenants can hold a live voice call
  whose media runs on their own paired laptop, with the control plane relaying only
  signaling (two SDP blobs), never RTP. A new `VoiceStore` (`cloud/voice.py`, a clone
  of the `JobStore` driver seam + partial-unique-index single-active-session guarantee)
  backs seven unconditional `/voice/*` broker routes (device-token laptop answerer +
  session-cookie browser offerer, per-tenant isolation copied from `/jobs/*`);
  per-tenant `voice_available` on `/live`; the cockpit restores a real voice Connect
  (`connectVoice()`) with an honest "start voice on your laptop" offline state. The
  laptop runs `yothere voice` (`cloud/voice_agent.py`): it long-polls `/voice/next`,
  drives the unchanged media host in-process, and posts the SDP answer back. Managed
  TURN (A3) and ephemeral-token billing (A4) landed in 1.5.5. (#50)

### Fixed
- **Cockpit inbox reply field no longer eats typing (dogfood-2).** The inline reply
  `<input>` lives inside `#inbox`, which `render()` rebuilt on every 1.5s SSE tick —
  destroying the field mid-type (lost caret + focus, "can't write more than a word").
  `render()` now skips the inbox rebuild while a reply row is focused (keyed off the
  live `document.activeElement`), and a send blurs first so the guard releases and the
  answered card animates out. The fragile `focusTid` refocus is retired. (#49)
- **Hosted discovery chip stops contradicting the header (dogfood-2).** A paired tenant
  saw both "Machine connected ✓" and "Connect a machine to discover tasks." The tasks
  setup chip now also reads `compute_available`: connected-but-no-discovery reads
  "Task discovery runs on your machine (coming soon)" (buttonless); unpaired keeps
  today's "Connect a machine to discover tasks." (The proper fix — routing
  `/tasks/discover` through the `/jobs` queue so discovery runs on the tenant's laptop —
  remains a separate, larger Phase-2 effort.) (#49)
- **BYO first-run on a stock Mac**: machine pairing works out of the box, the
  onboarding flow shows an explicit "task queued — connect a machine" wait, typed
  dispatch is no longer verb-gated, and a render can't steal command-bar focus.
  (#46, #47, #48)

## [1.5.3] — 2026-07-05

First-run/dogfood release. **Bumps past the 1.5.2 already on PyPI** (that wheel was
cut before the BYO-compute commit, so `pip install yothere` shipped a client with no
`login`/`service`); 1.5.3 is the first PyPI build where the BYO client verbs exist.

### Added
- **Cockpit "Connect a machine"** (hosted): the dead voice-Connect is repurposed to
  pair a machine, an amber "no machine connected — dispatched tasks wait" banner shows
  until a worker is live, and both open a pairing panel that mints a device token with
  the exact `yothere login`/`service` commands. Backed by a per-tenant
  `compute_available` signal (`device_tokens.last_seen_at`).

### Fixed
- **First-run cockpit read as "dead"**: Dispatch now disables until a task is typed
  (and re-syncs on delegate/pageshow), empty submit hints instead of silently
  returning, disabled voice/settings buttons carry honest tooltips, first paint shows a
  loading placeholder (with a reconnect watchdog) instead of a false empty state, and
  the hosted task-discovery Connect is honest instead of a silent no-op.
- **Rebrand**: remaining user-facing "Relay" on the login/signup/onboarding surfaces
  and the live-call transcript label are now "yothere".

## [1.5.1] — 2026-07-03

Version-integrity release — plus, as the tag was finally cut, the
relay→yothere rename and the worker-capability default (both under Changed
below; rehomed here from Unreleased during the 2026-07-08 changelog grooming,
since the v1.5.1 tag points at the rename commit).

### Fixed
- **Wheel version metadata** — the v1.5.0 tag was cut without bumping
  `pyproject.toml`, so the published "v1.5.0" wheel self-reports 1.4.0. This
  release restores tag == package version (1.5.0 is skipped as ambiguous).

### Added
- **`relay init` — zero-key first run** (`relay.cli`, new `relay.demo_brain`):
  one idempotent command that creates the `RELAY_HOME` layout, detects any
  existing brain (env url / `brains.yaml` / `claude` CLI), seeds a commented
  `brains.yaml` with a bundled demo brain when the file doesn't exist (never
  overwrites anything), and prints the exact next commands. `relay init --demo`
  starts the bundled Brain Protocol v1 demo brain on a free localhost port and
  drives one canned thread through the real runner -> worker -> brain path
  (blocked -> reply -> done), filling the board with no API key.
- Remote-brain `done` turns now also write `summary` (not just `last_result`),
  so a remote brain's final answer actually renders on the board / `relay show`.
- **Release guard** in `.github/workflows/release.yml`: the job fails early when
  the pushed tag does not match the `pyproject.toml` version, so a metadata
  mismatch can never publish again.
- `docs/RELEASING.md` — the release checklist (bump + changelog + tag + verify).
- Tester onboarding guide (`docs/ONBOARDING.md`) + tester-feedback issue
  template (#12; merged after the v1.5.0 tag, so first shipped in this wheel).

### Changed
- **RENAME: the package, module, and distribution are now `yothere`**
  (formerly `relay-cockpit`). Concretely: the import path `relay.*` -> `yothere.*`
  (`src/relay/` -> `src/yothere/`); the dist name `relay-cockpit` -> `yothere`
  (extras `[voice]`/`[web]`/`[mcp]`/`[llm]`/`[postgres]` unchanged); the MCP
  console script `relay-mcp` -> `yothere-mcp` and the MCP server registration
  name `relay` -> `yothere`. (#26)
- **CLI verb: `yothere` is now the canonical command (`yothere <verb>`); `relay`
  is kept as a deprecated back-compat alias.** Both console scripts invoke the
  same CLI, so `yothere init`/`yothere board`/... and the old `relay ...` forms
  both work; help/usage and the printed next-step hints now say `yothere`.
- **Default home: `~/.yothere`** (was `~/.relay`) when neither `YOTHERE_HOME` nor
  `RELAY_HOME` is set. For back-compat, an existing `~/.relay` is used as a
  deprecated fallback when `~/.yothere` does not yet exist, so a host running the
  old default is not stranded. Any explicit `YOTHERE_HOME`/`RELAY_HOME` still
  overrides. The `relay.env` file and the `com.*.relay-*` launchd/systemd unit
  names are unchanged — a running host that pins `RELAY_HOME` keeps its wiring.
- **Env vars: `YOTHERE_*` is the going-forward namespace; every legacy `RELAY_*`
  name still works.** A back-compat shim (`yothere.envcompat`) mirrors the two
  prefixes bidirectionally at package import (over `os.environ`) and inside the
  env-file parsers, so a host still exporting `RELAY_HOME`, `RELAY_NOTIFIER`, etc.
  is unaffected. The legacy `RELAY_*` names are supported for at least this
  release; new configs should prefer `YOTHERE_*`.
- **SECURITY-RELEVANT DEFAULT: `claude`-harness worker turns now run
  `--permission-mode bypassPermissions` by default** (full capability), contained
  by a relay-owned PreToolUse send-gate injected via `--settings` (denies
  outward sends: send scripts, `git push`, HTTP/mail egress with a body, MCP
  send tools; plain GET reads stay allowed). Self-hosters upgrading get this
  posture change; opt out with `worker_permission_mode: default` in vt config or
  `RELAY_WORKER_PERMISSION_MODE=default`. The gate fails CLOSED: if its settings
  file cannot be written, the turn drops `--permission-mode` entirely. Hosted
  tenants are clamped to `default` unless the operator sets
  `RELAY_HOSTED_FULL_CAPABILITY=1`. The gate is best-effort argv inspection
  (defense-in-depth), not an adversarial sandbox. (#14)

## [1.5.0] — 2026-06-30

Customer-cockpit Session 4b: the locally-proven self-onboarding cockpit becomes a
deployable single-box hosted service (Fly.io), with tenant agents advancing
against a hosted brain over the Brain Protocol. (#11)

> Note: the published v1.5.0 wheel self-reports version 1.4.0 (the tag was cut
> without a `pyproject.toml` bump) — fixed in 1.5.1.

### Added
- **Postgres-backed AuthStore** (`[postgres]` extra) — a one-file driver seam in
  `relay.voicecall.auth`: `DATABASE_URL` set → Postgres via lazily-imported
  psycopg; unset → SQLite, byte-identical to before. Schema self-initialises on
  first connect; public method surface unchanged.
- **Per-tenant hosted brain** — accounts gain nullable `brain_url`/`brain_token`
  + `auth.tenant_brain()` (per-account override, else deploy-wide
  `RELAY_HOSTED_BRAIN_URL`/`TOKEN`); the tenant supervisor injects
  `RELAY_REMOTE_BRAIN_URL`/`TOKEN` per tenant so each runner's `remote` harness
  advances with that tenant's token.
- **`relay.web` + `config.web_config()`** — boot the FastAPI cockpit without the
  Twilio/Gemini required-keys gate (voice degrades to "unavailable"); lean
  `[web]` extra (FastAPI/uvicorn only, no media stack).
- **Container + deploy**: `deploy/{Dockerfile,entrypoint.sh,fly.toml}`
  (python:3.12-slim, tini, one always-on machine, durable `/data` volume) +
  `docs/DEPLOY-fly.md` runbook.
- CI: dedicated Postgres parity job (`tests/test_auth_pg.py`) + off-mode parity
  suite (`tests/test_offmode_parity.py`) locking the local-first invariant.

## [1.4.0] — 2026-06-30

### Added
- **Opt-in keyless tailnet Connect (`RELAY_TRUST_TAILNET_IDENTITY=1`)** — the
  bearer-gated cockpit routes accept the verified `tailscale-user-login` header
  injected by `tailscale serve`, so a tailnet browser connects without the token
  in the URL. Spoof-safe: the identity is trusted only when no cloudflare edge
  marker (`cf-ray`/`cf-connecting-ip`) is present, so the public cloudflared
  tunnel can never forge it. Default (flag unset) is byte-identical:
  fully bearer-gated. (#10)

## [1.3.0] — 2026-06-30

Customer-cockpit Session 4: self-onboarding, local-first. A stranger redeems an
invite, signs up, and is walked through a guided first task — zero terminal
steps. All surfaces are hosted-mode-only; off mode stays byte-identical.

### Added
- **Self-onboarding (hosted mode)** — invite-gated `GET/POST /signup` +
  guided `GET/POST /onboarding` (same spawn front door as `/dispatch`); invites
  table with atomic single-use consume, per-account `budget_usd`, and a
  `relay invite create` CLI. Both routes 404 in off mode. (#8)
- **Runner-per-tenant supervisor** (`relay.tenant_supervisor`) — reconciles one
  `relay.runner loop` per tenant with per-tenant `RELAY_HOME`/`RELAY_USER` and a
  daily cost cap (`RELAY_DAILY_COST_CAP_USD`), so non-Phil tenants' threads
  actually advance. (#8)
- **MCP server surface** (`relay.mcp_server`, `relay-mcp` console script, `[mcp]`
  extra) — drive a fleet from any MCP client over stdio: `spawn_thread`, `board`,
  `reply`. Wraps the same spawn/store/reply front doors as the CLI/cockpit; the tool
  logic is SDK-free `_*_impl` helpers so it imports + tests without `mcp` installed.
  Single-user/local (drives `~/.relay`); orthogonal to the hosted cockpit. (#7)

## [1.2.0] — 2026-06-30

Customer-cockpit Session 3: real auth + multi-tenant isolation, gated entirely
behind a deployment-mode flag so local single-user use is byte-identical to before.

### Added
- **Hosted mode (`RELAY_AUTH_MODE=hosted`)** — a Python-native, stdlib-only auth
  layer (`relay.voicecall.auth`): scrypt password hashing, opaque session token in
  SQLite (`RELAY_AUTH_DB`, WAL + busy_timeout, separate from fleet state), login
  lockout, KDF-on-unknown-user (no existence timing leak), token rotation on login,
  and a `Secure` cookie tied to the mode (not the request URL).
- **Per-tenant isolation** — each account is scoped to its own `~/.relay-<tenant>`
  home under `RELAY_TENANTS_ROOT`. The cockpit resolves the tenant per request and
  threads an explicit `root=` into spawn/store/fleet_state — never the process
  singleton — so two logins see two isolated fleets with no cross-tenant read.
- `GET/POST /login` + `POST /logout` routes and a brand-consistent login page; the
  workspace chip now renders the logged-in account (dynamic; off mode = static).
- `fleet_state.load_fleet(root=…)`, `config.auth_mode`, `config.tenants_root`,
  `config.auth_db`.

### Changed
- In hosted mode the tailnet/loopback public carve-out is removed: every route
  except `/healthz` + `/login` requires a session. Off mode is unchanged (the gate
  short-circuits to the original path; no auth/tenant code runs).

## [1.1.0] — 2026-06-30

Architecture-audit pass: re-applied the 84-finding relay audit (originally landed
in the bob monorepo) against the package layout. Net ~1250 lines removed, runner
reliability hardened, voicecall teardown made deterministic, test suite raised to
231 (relay) + 177/3-skip (voicecall).

### Removed
- **The dead offline router + feedback-learning loop** — `router.py`,
  `router_learn.py`, and the `relay feedback` CLI subcommand are deleted (the
  cascade that called `router.decide()` was retired conceptually but never removed;
  it never ran in production). The live spawn-path classifier is `scope.py`, which
  is unaffected.
- **`attention.should_ping` / `push_pings` / `maybe_llm_rerank`** and the divergent
  fleet-ping interval constant — the live nudge gate lives in the runner; the
  attention copies were dead and drifting.
- Dead voicecall-brain probes (`connect()`/`hello()`), `dispatch` GREETING/GOODBYE
  constants, and `scope.log_case`'s unwired logging sink.

### Fixed (reliability)
- **Stale-429 false hold** — the 429 scan is now scoped to the current turn, so a
  prior turn's rate-limit no longer falsely holds a healthy thread.
- **Silent cost-ledger overspend** — a record_cost write failure now emits a
  rate-limited nudge / stderr signal instead of being swallowed, so the daily cap
  can't be defeated with zero signal. The cost ledger is compacted by maintenance.
- **Voicecall teardown leaks** — `SessionManager.cancel_all()` cancels in-flight
  bg sessions and releases `worker.lock` fds on call-end/evict; `evict()` awaits
  the cancelled runner; the sessions dict is bounded; `config.load` raises
  `ConfigError` instead of `SystemExit`.
- Quiet-hours honors the configured timezone (defensive `getattr` for callers
  without a `timezone` field); PTT mode gets the transcribe watchdog; `listen`
  uses `presence.runner_alive()` instead of the false-green pgrep probe.
- `send_nudge` extracted into `relay.notify` so the watchdog stops importing the
  whole runner engine.

### Changed
- `RELAY_THREADS_DIR` is `mkdir`-guarded; `runner.lock` records its PID; the
  needs-eyes nudge count aligns to the freshly-changed subset; `_WAKE` regex and
  the fleet state vocabulary are each single-sourced.

## [1.0.0] — 2026-06-29

First standalone release, extracted from the bob monorepo into `phios-ai/relay`.

### Added
- **Pip-installable package** `relay-cockpit` with the `relay` console script and
  a `[voice]` extra for the Gemini-Live surface, `[llm]` for the optional routing
  tiebreak.
- **`relay.config.settings`** — one env-overridable config rooted at `~/.relay`,
  replacing the bob monorepo path anchoring.
- **`relay.thread_model`** — the de-vendored dir-per-thread Thread model, now a
  first-class package (the old exec-based import landmine is gone).
- **Brain registry** (`relay.brain.registry`) — config-driven named brains in
  `~/.relay/brains.yaml`, so a thread can target a local Claude, a ubob daemon, or
  any remote Brain-Protocol endpoint by name.
- **Per-user scoping** (`RELAY_USER`) — a shared fleet no longer cross-shows
  another person's threads.
- **`sessionStatus`** — an additive Brain-Protocol v1 method + a remote fleet
  source, so a client with no local thread filesystem (phone, web, another
  machine) can render the board by querying the brain. This is the "use it
  online" path.

### Changed
- The headless worker is spawned via `python -m relay.worker`; the notifier,
  launchd label, and all paths are config seams.

### Notes
- Safety/permissions and real cost budgets live on the brain side for remote
  brains — see [SECURITY.md](SECURITY.md).
