Metadata-Version: 2.4
Name: latent-axol
Version: 0.3.2
Summary: Latent Market sidecar for the Almond Axol: seeds, manifests and delivery for LeRobot v3 datasets.
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: axol
Requires-Dist: almond-axol[lerobot]==0.2.25; extra == "axol"
Provides-Extra: parquet
Requires-Dist: pyarrow>=14; extra == "parquet"

# latent-axol: connect an Almond Axol to Latent

**Not live yet: the server half (F tasks 4 to 16) is not built; until then pairing, the agent and delivery work only against `tests/fake_cells.py`.** Everything below in the present tense describes the robot side as built and tested against that fake; see "Not built yet on the server".

`latent-axol` connects an Axol cell to Latent with an SSH login and the sudo password as the only typing, binds every take recorded in the Axol control panel to a fresh Latent seed, and delivers it, signed by the robot, 20 seconds after the save. The manual commands (`orders`, `seeds`, `deliver`, `status <order>`, `card`, `config`) remain as the fallback.

Python 3.9 or newer, standard library only at import time. `pyarrow` is used when it is there (the agent's own environment installs it; the Axol environment ships it), and `almond_axol` is imported only inside the control panel's process. The contract this package implements is `docs/robot-connect-contract.md`; section numbers below refer to it.

No em dashes, no email in any request: the only identifying header is `user-agent: latent-axol/1`.

## Connect a robot: what the provider does

| # | Where | Step |
|---|---|---|
| 1 | console | **Robots**, then **Connect a robot**: a one-line install command that carries a single-use enrollment token (60 minutes), with a copy button. |
| 2 | console | Copy it. |
| 3 | laptop | SSH to the robot. |
| 4 | robot | Paste `curl -fsSL https://latentenergy.com/robot/install.sh \| sudo bash -s -- --enroll enr_...` |
| 5 | robot | Type the sudo password. The script installs the hooks and the agent and starts the agent, which asks the provider's console for approval. The terminal can be closed from here on: if the server cannot be reached at that moment, the robot keeps the token (0600) and retries by itself until the token's hour is over, and says "Cannot reach Latent; the robot keeps trying until HH:MM. You can close this terminal." |
| 6 | console | The robot appears with its hostname, cameras, key fingerprint and a prefilled station: **Approve**. "Connected" follows at the robot's first heartbeat, with the card display QR code. |
| 7 | tablet | Scan the card display QR code once (it is in the console and in the terminal). |
| 8 | panel | **Collect data** (or **Collect DAgger data**). The **Latent order** field already reads `auto`, naming the one open order and the provider. |
| 9 | panel | **Start**. |
| 10 | panel | Record as usual, from the headset or the panel. |
| 11 | panel | Intervention orders only: tap the failure class. |

Each take is delivered 20 s after its save; the panel's brief shows the checks ("Delivered unit 4 of 10: 9 pass, 1 warn"). Nobody copies a key, writes `latent.json`, opens a tunnel, issues seeds, types a nonce, a pairing code or a URL, names a dataset folder, restarts a service, answers a prompt or runs `deliver`.

**Without a token.** A robot paired before (after `unpair`, a revocation or a lost credential) needs no terminal: the panel's Latent order field reads "Latent not connected: open latentenergy.com/pair and enter WDJB-MJHT (until 14:13)" (the page is the server's own `verification_uri`, so staging and dev name theirs), and `latent-axol pair` prints the same code with a QR code that opens the approval page on a phone. When no code can be offered (right after `unpair`, inside the 10-minute limit on new codes, or with the server down) the field says so ("Latent not connected: run latent-axol pair", "Latent server unreachable, retrying"), never "no open order".

**What runs by itself.** Pairing survives a closed terminal, a dropped SSH session, a reboot and a server that cannot be reached at the first try (the agent owns it, resumes the same device code, and retries a start it could not make while the install line's token is younger than 60 minutes). A lost answer is recovered by the next poll. The control panel restarts itself at an idle moment to load the hooks. A take saved offline is delivered when the robot is back.

**What does not run by itself yet.** `RELEASE_KEYS` is empty, so nothing is ever verified or installed by the robot. One press of **Update** in the Axol control panel that moves almond-axol away from 0.2.25 turns the Latent hooks off until someone acts: the Latent order field disappears and takes record as stock. The SSH login line, `latent-axol pair` and `latent-axol status` then say what is true and what to do: below 0.2.25, "press Update in the Axol control panel"; above it, "Latent recording is off until a latent-axol release supports almond-axol X; to override, put LATENT_AXOL_ALLOW_SDK=X in /etc/latent-axol/env". `pair` says it at install time too, from the Axol environment's installed version. The "updates itself" wording appears only once a signed release list exists.

**Where problems show:** the panel's brief (before the first take: "Not recording for Latent" with the reason; after a take that could not be delivered, a plain sentence with the next step, and a **Retry delivery** button for a held take), the Start error (the reason first: "Latent agent stopped: it restarts by itself; if this stays, run latent-axol doctor", "Latent server slow, retrying", "Latent not connected: run latent-axol pair", "Not started: order 9eb04eec has no free recording code: code 9eb04eec-1 was issued on the order page. Release it on the order page, or pick another order. Nothing moved."), the console's Robots row (from the heartbeat), the SSH login line, `latent-axol status` and `latent-axol doctor`.

## Install

The console's line runs `install.sh` (served by the app, section 6.1; not built yet, F task 16). Until it exists this is the manual equivalent: section 6.2 of the contract, with the served wheel downloaded and checked against the published sum, and the operator worked out instead of typed.

The wheel is served by the app at `https://latentenergy.com/tools/latent_axol-<version>-py3-none-any.whl`, next to `https://latentenergy.com/tools/SHA256SUMS` (both from `public/tools`; `node scripts/copy-tools.mjs` writes the sums at every build). latent-axol is never installed by name from a package index: only the served file, checked against the sum. Until latentenergy.com serves the app (docs/cutover.md), take the wheel and `SHA256SUMS` from `public/tools` of this repository instead and copy both to the robot's `/tmp`.

On the robot, after the SSH login, as root (`sudo -s`, which keeps `SUDO_USER`):

```sh
set -eu
VER=0.3.0
CHECKER_VER=0.1.3                                   # the people-in-frame check (optional, step 3b)
ORIGIN=https://latentenergy.com                     # or https://staging.latentenergy.com (install.sh --origin)
NAME=latent_axol-$VER-py3-none-any.whl
LOCAL=/tmp/$NAME
curl -fsSL --proto '=https' -o "$LOCAL" "$ORIGIN/tools/$NAME"
curl -fsSL --proto '=https' -o /tmp/SHA256SUMS "$ORIGIN/tools/SHA256SUMS"
WHEEL_SHA256=$(grep " $NAME\$" /tmp/SHA256SUMS | cut -d' ' -f1)
[ -n "$WHEEL_SHA256" ] && echo "$WHEEL_SHA256  $LOCAL" | sha256sum -c -   # stops here on a mismatch
ENROLL=                                               # the enr_ token from Connect a robot, or empty
OP="${SUDO_USER:-$(stat -c %U "$(ls -d /home/*/ | head -n 1)")}"
AXOL_PY=/opt/axol/uv/tools/almond-axol/bin/python
WHEEL=/opt/latent/wheels/latent_axol-$VER-py3-none-any.whl

# 1. directories, and the wheel checked against the published hash again in place
install -d -m 0755 /opt/latent /opt/latent/wheels /opt/latent/bin /etc/latent-axol /var/lib/latent-axol
install -d -m 0700 /var/lib/latent-axol/inbox
cp "$LOCAL" "$WHEEL.part"
echo "$WHEEL_SHA256  $WHEEL.part" | sha256sum -c -
mv "$WHEEL.part" "$WHEEL"

# 2. the hooks, into the Axol tool environment (the SDK's own plugin idiom,
#    plugins/lerobot_robot_axol/README.md:48-60; a bare uv pip resolves the wrong environment)
/usr/local/bin/uv pip install --python "$AXOL_PY" --no-deps --reinstall "$WHEEL"

# 3. the agent, as its own uv tool with its own Python, untouched by panel Updates
env UV_TOOL_DIR=/opt/latent/uv/tools UV_TOOL_BIN_DIR=/opt/latent/bin UV_PYTHON_INSTALL_DIR=/opt/latent/uv/python \
    /usr/local/bin/uv tool install --force --python 3.13 --with "pyarrow>=14" "$WHEEL"

# 3b. people in frame (optional; skip it to leave the check out): latent-checker, numpy and OpenCV
#     in the agent's environment and the two detector models, fetched from pinned commits and
#     checked by SHA-256. If any line here fails, leave the two LATENT_CHECKER_ lines out of step 4.
CHECKER_WHEEL=/opt/latent/wheels/latent_checker-$CHECKER_VER-py3-none-any.whl
curl -fsSL --proto '=https' -o "$CHECKER_WHEEL.part" "$ORIGIN/tools/latent_checker-$CHECKER_VER-py3-none-any.whl"
echo "$(grep " latent_checker-$CHECKER_VER-py3-none-any.whl\$" /tmp/SHA256SUMS | cut -d' ' -f1)  $CHECKER_WHEEL.part" | sha256sum -c - && mv "$CHECKER_WHEEL.part" "$CHECKER_WHEEL"
/usr/local/bin/uv pip install --python /opt/latent/uv/tools/latent-axol/bin/python "$CHECKER_WHEEL" "numpy==2.4.6" "opencv-python-headless==5.0.0.93"
install -d -m 0755 /opt/latent/models
/opt/latent/uv/tools/latent-axol/bin/python -m latent_checker models fetch --dir /opt/latent/models
chmod -R a+rX /opt/latent

# 4. the environment file both units load: no LATENT_ORIGIN (the credential names its origin, 4.6)
cat > /etc/latent-axol/env <<EOF
LATENT_HOME=/home/$OP/.latent
LATENT_RUNTIME_DIR=/run/latent-axol
LATENT_OPERATOR=$OP
LATENT_AXOL_WHEEL=$WHEEL
LATENT_CARD_BIND=0.0.0.0:8790
LATENT_AUTO_UPDATE=needed
LATENT_PANEL_AUTO_RESTART=1
LATENT_CHECKER_WHEEL=$CHECKER_WHEEL
LATENT_CHECKER_MODELS=/opt/latent/models
EOF
install -d -m 0700 -o "$OP" -g "$(id -gn "$OP")" "/home/$OP/.latent"
printf '{"version":"%s","sha256":"%s","installed_at":%s}\n' "$VER" "$WHEEL_SHA256" "$(date +%s)" > /var/lib/latent-axol/installed.json

# 5. units, drop-in, wrapper, apply script, login line and /usr/local/bin/latent-axol (6.3 to 6.6),
#    from the package's own texts; AFF is the SDK's "background" core group, worked out here
AFF=$("$AXOL_PY" -c "from almond_axol.utils.affinity import core_groups; g = core_groups(); print(' '.join(map(str, sorted(g['background']))) if g else '')" || true)
/opt/latent/bin/latent-axol system-files --write --operator "$OP" --cpu-affinity "$AFF"
systemctl daemon-reload

# 6. start the agent and the maintenance timer; the first maintenance run restarts the
#    control panel as soon as it is idle
systemctl enable --now latent-axol-agent.service latent-axol-maint.timer
systemctl start --no-block latent-axol-maint.service

# 7. pair; the agent keeps waiting (and retries an unreachable server) if this terminal closes
if [ -t 1 ]; then WAIT=""; else WAIT="--no-wait"; fi
sudo -u "$OP" /opt/latent/bin/latent-axol pair --origin "$ORIGIN" ${ENROLL:+--enroll "$ENROLL"} $WAIT
```

Nothing above is typed by hand except `ENROLL`. A developer with this repository builds the same wheel with `python3.11 -m pip wheel tools/latent-axol --no-deps -w public/tools` (the macOS system `python3` 3.9 with pip 21 builds an empty `UNKNOWN` wheel from this `pyproject.toml`; any Python with pip 23 or newer works), then `node scripts/copy-tools.mjs` to refresh `SHA256SUMS`. `--cpu-affinity "$AFF"` stays: on a first install there is no agent unit yet to read the value from, so leaving the flag out would drop the CPU pinning. After step 5, `latent-axol` is on every user's PATH (`/usr/local/bin/latent-axol` runs `/opt/latent/bin/latent-axol`), so every "Next: latent-axol ..." the robot prints works as typed.

`latent-axol system-files --write` writes exactly the texts of section 6 (held in `latent_axol/systemfiles.py`): `/opt/latent/bin/axol-serve-latent`, `latent-axol-agent.service`, the `axol.service.d/latent.conf` drop-in (it reinstalls the hooks from the local wheel before every panel start, so a panel Update never loses them), `latent-axol-maint.service` and `.timer`, `/opt/latent/bin/latent-maint-apply`, `/etc/update-motd.d/90-latent-axol` and `/usr/local/bin/latent-axol` (0755, `exec /opt/latent/bin/latent-axol "$@"`; a wrapper, not a symlink). `latent-axol doctor` checks that the command is found on the PATH. It refuses a symlink at any destination; `--check` reports differences without writing. Nothing in this package starts a process: systemd and the two shell scripts run the shell lines.

**Who owns what.** The agent runs as the operator and owns `LATENT_HOME` (`/home/<operator>/.latent`, 0700; every file 0600, written atomically with a random temporary suffix). Root processes (the hooks in the panel, `maint`, `install.sh`, `latent-maint-apply`) never open a path under the operator's home and nothing calls `chown`; root keeps `/var/lib/latent-axol` and `/run/latent-axol-hooks`, and talks to the agent only over the socket `/run/latent-axol/agent.sock` (0700 directory, peer uid checked both ways).

**Roll back and remove:** section 6.9 (`latent-axol unpair`, disable the timer, remove the drop-in, restart `axol`, uninstall both copies, remove `/opt/latent`, `/etc/latent-axol`, `/var/lib/latent-axol` and the units), plus `sudo rm /usr/local/bin/latent-axol`, which 6.9 does not list yet.

## Pairing

`latent-axol pair [--enroll TOKEN] [--origin URL] [--dev] [--label L] [--station S] [--no-probe] [--new-key] [--no-wait] [--json]` (section 4.5). It refuses root, asks the agent over the socket (or runs the same state machine in its own process when no agent answers within 10 s, as on a Mac) and prints:

```
  Connect this robot to Latent

  Asking Acme Robotics to approve it: their Connect a robot page lists it now.
  Or on any phone or computer: scan this, or open https://latentenergy.com/pair
  and enter WDJB-MJHT.

  [QR code]

  Robot key   If4x 36FU   (the approval page shows the same)
  Waiting until 14:13. You can close this terminal: the robot keeps waiting by itself.
```

then "Connected to Acme Robotics (acme-robotics) as Axol bench 1, station providence-104", the card display QR code and the hooks line. There is no question to answer anywhere.

- The robot's Ed25519 key (`keys/cell.ed25519.json`, the file the Node CLI uses) proves possession at `start` and on every poll; nothing is registered until the first valid poll after the approval.
- `credentials.json` is written the moment the poll answers 200, before anything else. A dropped answer is recovered by the next poll (the server re-mints). An interrupted pairing resumes with the same device code (`state/pairing.json`, locked with `flock` so two processes never poll it).
- Already paired: the command checks `cells/me` and exits 0 ("Already paired to ..."), so re-running the installer is harmless. A revoked credential is archived and pairing starts again with the same key, so a re-pair within 24 h adopts the seeds of the takes the robot still holds; a `compromised` revocation or `--new-key` makes a new key.
- `latent-axol unpair [--local] [--keep-key]` revokes on the server, deletes the credential, the cell state and the key file, and stops the panel from offering codes until the next `pair`.
- Origins: only `https://latentenergy.com` and `https://staging.latentenergy.com` (compiled in). The robot credential is always sent to the origin stored beside it; `LATENT_ORIGIN` and a `latent.json` origin apply only to a personal API key (section 4.6). Every redirect is refused, so the credential never reaches another host.

## The agent

`latent-axol agent` (systemd, as the operator; `--once` runs one heartbeat, one orders sync and one queue pass). Threads: the socket server, pairing, the heartbeat (30 s, sooner after a take changes state, never more than one in 10 s), the orders sync (`GET /api/v1/cells/me/orders`), the panel probe (loopback `https://127.0.0.1:8001`), the delivery queue, the card display and, with latent-checker installed, the people scan (see "People in frame").

- **Request proofs.** Every request made with the robot credential carries `Latent-Cell-Proof: 1.<cell key id>.<ts>.<nonce>.<sig>` (section 3.8), so a leaked bearer string alone is worthless. The clock offset comes from every answer's `Latent-Server-Time`.
- **Seeds.** One armed seed, refreshed while idle with `replace_seed_id`, plus one buffer seed when the order's room is at least 3. A take never starts less than 1 s after its seed; the wait in the recorder's `start_episode` is bounded at 1.5 s. The arms for the next take (`start`, `ready`) send `take_over_unbound`: on a robot-mode order with no room the server releases the oldest unused order page code and issues a fresh one in the same transaction (event `seed_released`, reason `taken_over_by_robot`); the old nonce is never handed over. `arm` is idempotent per session: a seed issued for a call whose answer the hooks never got (a timeout) goes to that session's next arm instead of a new one. Every arm failure is logged with the server's 409 body (who holds the codes), and `orders` is answered from the cache while the orders thread syncs, so a slow network never reads as a stopped agent. The list holds only orders with something `recordable` (room, this robot's open codes, order page codes it may take over).
- **The queue** (`queue/takes/<take_id>.json`): `waiting` (20 s, `LATENT_DELIVER_DELAY_S`) -> `staging` -> `staged` -> `previewing` -> `delivering` -> `delivered`, with `needs_input` (a failure class or failed log), `withheld` ("Do not deliver"), `retrying` (10 s, 30 s, 1, 2, 5, 10 min, then every 15 min, 20 percent jitter, `Retry-After` wins), `refused`, `needs_attention`, `paused` (revoked; re-pairing within 24 h resumes), `lost_to_revoke`, `expired`, `orphaned`. Jobs survive restarts; a retry after a lost answer first looks for a unit with the take's seed, and when that lookup fails it waits and looks again instead of posting again (the server ignores `idempotency_key` until F task 15). Two takes of one dataset staged with no save between them freeze identical shared meta files; a refusal whose only failures are `duplicate` on `meta/info.json`, `meta/stats.json` or `meta/tasks.parquet` keeps the seed and retries (needs_attention after 12 tries) until the server's dedupe exemption (p2p3) exists. A full disk while staging reads "Disk full: free space on the robot (N GB free)" in the brief and in `status`, and retries with backoff.
- **Staging** writes nothing into the dataset: the episode is found by its `[latent:<nonce>]` tag, `meta/info.json`, `meta/stats.json` and `meta/tasks.parquet` are copied out under the SDK's `meta/.metadata.lock`, the take file (`latent-take-file/1`) is written once in `takes/<take_id>/frozen/meta/latent_take/episode_<index>.json` and delivered under that path. A dataset holding another order's tag is refused locally.
- **Signing.** The agent previews the manifest, signs the server's hash only under the five conditions of section 4.4, and sends the signature both as the manifest's `cell_signature` block and as p2p3's body `signature` (`LATENT_SIGNATURE_CARRIER`: `both`, `manifest`, `body`). Deliveries carry `idempotency_key: <take_id>`.
- **Latent custody** (p2p3 7.2). When `GET /api/v1/storage` says custody is enabled (checked every 5 minutes; `LATENT_CUSTODY=off` keeps the cell's `storage_base` URL), an `uploading` stage runs between `staged` and `previewing`: the take's files are hashed while files of at most 64 MiB are copied into `takes/<take_id>/upload/`, the upload is created (with `replace`, so a re-staged take never strands an old upload), each file is PUT to its slot with fresh slots before every retry, the upload is completed, and `storage_uri` becomes `latent://upl_...`. While Latent re-hashes the stored bytes (`verifying`) the take waits 15 s between looks and resumes the same upload without restaging; a larger file that the recorder changes mid-upload, or an upload that expired, sends the take back to staging. The staging copies are removed when the take is final.
- **Console commands** arrive in heartbeat answers (`set_failure_class`, `set_failed_log`, `set_outcome`, `withhold`, `deliver_now`, `retry`, `refresh_orders`, `release_seed`, `prepare_start`, `cancel_start`) and are acknowledged in the next heartbeat; any other kind is acknowledged `unsupported`. No command runs code, changes configuration or moves an arm.
- **The card display** (`LATENT_CARD_BIND`, default `0.0.0.0:8790`): `http://<LAN address>:8790/c/<32 hex>` serves a static page that draws the current seed's QR from `card.modules`; only loopback, private and link-local peers get it. `latent-axol card --new-url` makes a new address.

## The control panel hooks

`axol.service` runs `latent-axol serve` from the Axol environment (section 4.10). It replaces `collect-data` and `collect-dagger` by id through the SDK's own registry: `repo_id` and `task` default to empty, and three per-run fields appear: **Latent order** (strict; `auto` first, one row per open order, practice orders last, `none`), **Latent operator** and **Latent failed log**. `auto` resolves at Start to the one open order, else the order used last, else the practice order; several open orders with none used before refuse with "3 Latent orders are open for Collect data: pick one in Latent order, or none". The dataset is `latent/<order 8>-<UTC time>`, the task is the order's instruction, and the recorder's task carries `[latent:<nonce>]` per take. The brief shows the order, the seed and its nonce, the instruction and the result; the Latent buttons (`Succeeded`, `Failed`, `Deliver now`, `Do not deliver`, the six failure classes) never reach the SDK's queue.

**No take without a code** (task start plan, phase 1). A session bound to an order arms its seed before the SDK's run function, so before anything moves (hub firmware, gripper calibration and the move to rest all run inside it): with no seed in 10 s the session ends with "Not started: <reason>. <fix>. Nothing moved." While a bound session has no seed armed or buffered, the panel's **Start** and the headset's record press are dropped (the headset is snapped back to data collection, as the SDK does in a contact hold) and the brief, the panel notice (error tone), the heartbeat and the card display ("No code: <reason>", or full-screen red "NOT RECORDING FOR LATENT" during a bound session) say why. The headset gate needs `AxolVRTeleop.get_teleop_events` and `VRState` (`sdk.GATE_NAMES`); when they are missing or changed only that gate is off, with a `doctor` warning. A take that still records without a seed is kept as an `unbound` queue job (reason, dataset, episode, times): listed in `queue`, `status`, `doctor` and the heartbeat, never staged, tagged or delivered, and kept across restarts.

The hooks fail open: any problem leaves the stock panel recording. They run only on almond-axol `0.2.25` (`SUPPORTED_SDK`), a version a verified signed release list accepts, or `LATENT_AXOL_ALLOW_SDK=<version>` in `/etc/latent-axol/env`; names and signatures are checked either way. `latent-axol maint` (root, every 2 minutes) fetches and verifies the signed list, stages a signed wheel when the hooks need one, restarts the panel only when that changes something and it is idle on two observations 60 s apart, and writes the SSH login line.

## Starting a task from the headset (task start plan, phase 2)

Arms move only after a 2 s hold on a page in the headset, sent over the robot's own USB cable. Nothing on the website or the server starts or stops an op: `prepare_start` and `cancel_start` move nothing, and no cell command reaches `POST /api/op/start`.

1. **Get ready** (`prepare_start` from the order page or the console), or plug the Quest into the robot's USB. The agent (`starter.py`) syncs the orders, reads the panel (never `GET /api/info`, which runs a root provision), detects the cameras while idle, connects the arm link with the motors off (`panelact.robot_connect`) and checks itself with the Ready check (`ready.py`): one `evaluate(state)` words every row on the start page, the station screen, the heartbeat, `latent-axol ready`, `doctor` and the login line, each with one fix and who acts (robot, operator, provider).
2. **The start page opens by itself.** The USB watcher in the root hooks (`usbwatch.py`, Almond's `utils.adb`) checks every 3 s; a newly authorized headset with no op running gets `adb reverse` for 8000, 8001 and 8790 (never while the reverse is active: Almond's connect re-adds the tunnels) and `am start` of `http://localhost:8790/s/<token>`, a token bound to that headset's serial.
3. **Hold Start for 2 s** after ticking "Workspace clear" and "Power switch in reach". The card server accepts the page's requests only from a loopback peer with `Host: localhost:8790`, as JSON with no foreign `Origin`; the confirm also needs adb to report that serial as `device` (a fresh `GET /api/usb/status`), the start still waiting (not cancelled, not older than 10 minutes), no op and no lockout, no failing Ready row, both ticks and a hold the server timed at 2 s or more. Then `AtRobotConfirm` and the single call site of `panelact.start_op`: collect-data with `latent_order`, `latent_start`, the operator, the cameras from detect plus the station's wrist serial map (a required stream with no camera blocks the start), and the station's contact limit only when the provider set one (4 to 20 Nm, shown before the hold; Latent never raises it).
4. **The hooks check again** before `run_fn`: a session with `latent_start` is refused ("Not started: ... Nothing moved.") unless the root env file says `LATENT_START=headset`, the agent reports the hold for that start within 60 s for that order (used once) and adb still reports the headset as `device`.
5. **The page narrates** from the session log (`sessionlog.py`): the hub firmware flash, gripper calibration, the move to rest, the VR link on :8000 and the Axol VR taps; contact stops ("Contact stop: left SHOULDER_2 7.6 over 6.0. Free the arms by hand, press X."), tracking loss and re-centring go to the heartbeat, the page and the station screen.
6. **Stop** is a 2 s hold under the same gate. The agent never calls `/api/op/stop`: the hooks get the request in the `status` answer and set that session's own stop event (the event Almond's Stop sets first; Almond's stop watchdog does not run, so a session that hangs in teardown is ended in the Axol panel). The idle reminder (15 minutes at rest with nobody in VR) only notifies.

**The kill switch** is `LATENT_START=off|observe|headset` in the root-owned `/etc/latent-axol/env`, read from the file at every check (`killswitch.py`; the agent drops the copy systemd loaded from the same file, so an edit applies without a restart): observe by default, so a held Start is logged as the `POST /api/op/start` it would send and nothing is posted; a file the operator could write is never trusted above observe, and the process environment can only lower it. `off` also stops the watcher's tunnels and page.

**The station screen** (`/c/<token>`, a phone in the overhead camera's view) shows the code while armed and for the first 3 s of a take, then goes dark; red "NOT RECORDING FOR LATENT: reason"; amber on a contact stop; green when delivered or the order is complete; and the notices below.

**Station settings** come with the cell profile in the heartbeat answer: `contact_limit_nm` (null, or 4 to 20) and `wrist_serials` {left, right} (ZED serials, 1 to 12 digits; the server refuses anything else), kept in `state/cell.json`.

**The heartbeat** gains `ready` {at, items}, `start` {state idle | preparing | waiting_at_robot | starting | running | blocked | cancelled, order_id, message} and `usb` {headset none | unauthorized | device, reverse}, and beats every 10 s while a start is preparing, waiting at the robot or starting (`starter.FAST_BEAT_STATES`, the states the server answers `interval_s` 10 for). `tests/fixtures/robot-wire/phase2.json` pins the wire on both sides.

### Retries at the robot (retry addendum R1 to R3)

- **R1.** Discarding a take (B in the headset, Discard in the panel) puts its code back for the next attempt; the brief, the start page, the station screen and the heartbeat read "Take discarded. Code 9eb04eec-2 is ready for the next attempt." When the next code is armed already, that one is named and the discarded code is released.
- **R2.** Every take started under a code is an attempt; the take record and the manifest's `episode.attempts` carry the count (optional: older agents omit it, and `episode.attempts` is in `OPTIONAL_MANIFEST_KEYS`). A released code hands its count to the next code, so a delivered unit counts every attempt it took. The count lives in the session: a new session starts from 1.
- **R3.** "Save as failed attempt" is a panel control during a take: the hooks mark the take and push the SDK's own Save. The take is kept as a `failed_attempt` queue job (`latent-failed-attempt/1`: its code, the reason, the attempt number, the times), its code is released on the server because its nonce is now in the dataset, and it is never delivered on a success order. Limit: Almond's collect-data has no failure save, so this exists only as a panel control; from VR a take is saved (A) or discarded (B).

## People in frame (optional)

When latent-checker is installed in the agent's environment, the agent scans each take for people in frame as soon as it is staged (about 20 s after the save): one frame per second, at most 60 per camera, with the checker's person, face and pet detectors (`latent_axol/people.py`, which imports `latent_checker.people` only when a scan runs). The scan runs on its own thread, one take at a time: it warns and never holds, delays or changes a delivery, and a scan that fails, takes longer than 5 minutes or is not installed is recorded as unavailable with its reason. The result is kept on the take's job (`people`, also in `queue --json`) and the panel's brief gets an item **People in frame**, for example "A person is visible in 88% of sampled frames (overhead_left 95%, left_arm 82%; 0 to 51 s). The buyer sees this check; Latent offers the buyer a copy with faces blurred." When the order's terms say `no_people_in_frame`, the brief's note turns to a warning: Latent pauses auto-accept and reviews that unit, so record the next take with nobody in view. A take with only an animal in view gets one informational line. The server runs its own `people_in_frame` check on every delivery either way; this is the robot's early copy of it.

**Installed by default.** `install.sh` (step 3b) puts the served latent-checker wheel (pinned by version and SHA-256 like the latent-axol wheel), numpy and OpenCV (pinned versions from the package index, as pyarrow is) into the agent's environment, fetches the two detector models into `/opt/latent/models` (pinned commits, SHA-256 checked; the robot needs to reach github.com for this one step), and adds `LATENT_CHECKER_WHEEL` and `LATENT_CHECKER_MODELS` to `/etc/latent-axol/env`. Any failure there prints one line and the install carries on without the check. `--no-people` leaves it out. A latent-axol update rebuilds the agent's environment (`uv tool install --force` in `latent-maint-apply`); the apply script then installs latent-checker again from the local wheel named by `LATENT_CHECKER_WHEEL`, and a failure there only prints a line. A control panel Update rebuilds the Axol environment, never the agent's.

Cost on the robot: one scan of a two-camera take of about 50 s peaked at about 460 MB of memory and took 14 s with two threads on an M-series Mac; the agent unit allows 1 GB (`MemoryMax=1G`) on the background cores at low priority, so expect it to be slower on the Orin's cores and to stay inside the limit. Not measured on a Jetson yet.

A robot installed before latent-axol 0.2.1 adds it by hand, as root:

```sh
CHECKER_WHEEL=/opt/latent/wheels/latent_checker-0.1.3-py3-none-any.whl   # from https://latentenergy.com/tools/, checked against SHA256SUMS
AGENT_PY=/opt/latent/uv/tools/latent-axol/bin/python
/usr/local/bin/uv pip install --python "$AGENT_PY" "$CHECKER_WHEEL" "numpy==2.4.6" "opencv-python-headless==5.0.0.93"
install -d -m 0755 /opt/latent/models
"$AGENT_PY" -m latent_checker models fetch --dir /opt/latent/models
chmod -R a+rX /opt/latent
printf 'LATENT_CHECKER_WHEEL=%s\nLATENT_CHECKER_MODELS=/opt/latent/models\n' "$CHECKER_WHEEL" >> /etc/latent-axol/env
systemctl restart latent-axol-agent
```

`latent-axol doctor` then shows "people check: available" (otherwise "not installed (optional)", or a warning that names what is missing; never a failure). `LATENT_AXOL_PEOPLE=off` in `/etc/latent-axol/env` (then restart the agent) turns the scan off without uninstalling anything. With the order's term the brief uses the `error` tone, which the panel already shows for a refused take.

`latent-axol people <dataset root or take folder> [--episode N] [--json] [--models DIR]` runs the same scan by hand and prints the summary: exit 0 when nobody is seen, 1 when people are seen, 2 when the scan cannot run here (with the reason and the fix). `--models` defaults to `LATENT_CHECKER_MODELS` from the environment, then from `/etc/latent-axol/env`.

## Local tests on your network (`--dev`)

**Not live yet: the app has none of the pairing or cell routes (F tasks 4 to 16), so this section cannot run against `npm run dev` today; it describes the flow once they exist. Until then the same flow runs only against `tests/fake_cells.py`.**

The owner's Mac runs the app on the home network (a person starts it; nothing in this package starts a server):

```sh
MARKET_CLIENT_IP_SOURCE=x-forwarded-for-last npm run dev -- -H 0.0.0.0
```

On the robot:

```sh
latent-axol pair --dev --origin http://<Mac LAN address>:3000      # an IP address or a .local name
sudo sh -c 'echo LATENT_DEV=1 >> /etc/latent-axol/env' && sudo systemctl restart latent-axol-agent
```

Enter the code at `http://localhost:3000/pair` on the Mac. A dev origin must be an IP literal or a `.local` name whose every address lies in 127/8, 10/8, 172.16/12, 192.168/16, `::1` or `fc00::/7`; the first address is pinned in `credentials.json`, so DNS is never consulted again. `pair`, `status` and every agent start print: "DEV MODE: paired with http://192.168.1.23:3000 over the local network. Anyone on this network can read the robot credential and the take data. Never use a dev pairing for real orders." The agent refuses a dev credential unless `LATENT_DEV=1`. The dev server returns sign-in links in responses when `RESEND_API_KEY` is unset, so use it on a trusted network with local data only.

## Commands

| Command | Purpose |
|---|---|
| `pair`, `unpair` | connect or disconnect this robot (above) |
| `status` | pairing, provider, agent, hooks, release, orders, queue, last take, card URL (`--qr`) |
| `status <order>` | units, checks and deadlines of one order (as before) |
| `doctor [--json]` | every check with its fix; exit 1 on any fail. Also the timed socket and server round trip, the network type, the start kill switch and the Ready rows a start needs (warnings) |
| `ready [--json]` | the Ready check in the words of the start page and the console; exit 1 while a row fails |
| `queue`, `queue retry <take> [--unsigned]`, `queue drop <take>`, `queue set <take> --failure-class X \| --failed-log REF \| --policy VERSION \| --success \| --failed` | the delivery queue (the panel's **Retry delivery**, failed-log and policy buttons do the same) |
| `deliver --take <take>` | run one queued take now |
| `card [--new-url] [--qr]` | the card display URL; `card <episode id>` reprints a stored seed's card |
| `rotate-key`, `rotate-credential` | replace the cell key (the old key signs the change; the new key is in `keys/cell.ed25519.next.json` before the request, and a lost answer is settled by asking the server which key it accepts) or the credential |
| `agent [--once]`, `serve` | the services (systemd runs them) |
| `maint`, `system-files --write \| --check` | root only |
| `release keygen --out FILE`, `release sign --key FILE PAYLOAD` | owner only, on an offline machine |
| `orders`, `seeds`, `deliver`, `config` | the manual flow; with the robot credential they run as the cell and `deliver` signs |
| `deliver ... [--custody \| --no-custody] [--sign \| --no-sign] [--resume upl_...] [--replace]` | Latent custody (default when the server has it) and the cell key signature (default when a registered key file exists); `--resume` continues an interrupted upload |
| `keygen [--force]`, `key register --label L [--robot ID]`, `key list`, `key revoke ID` | the cell key file and its registration through a personal API key (pending until activated in the provider console) |
| `calib <dataset root> --from-pyzed \| --from-conf SN.conf[=key]`, `profile <dataset root>` | write `meta/latent_calibration.json`; print a cell profile draft to paste into the console |
| `people <dataset root or take folder> [--episode N] [--json] [--models DIR]` | people in frame (needs latent-checker, see "People in frame"); exit 1 when people are seen, 2 when the scan cannot run |
| `fetch <order> <seq> [dir]` | buyer: download a unit's files from Latent custody and check every sha256 |

## Verification phases 2 and 3: custody, the cell key, seed cards, sidecars

`docs/cells.md` is the setup page; the contract is `docs/verification-p2p3-contract.md` section 21.2. In short:

- **The cell key** (`keygen`, `key register`): the key file `keys/cell.ed25519.json` is the same file the Node CLI and pairing use. A key registered through a personal API key is pending until the provider activates it from a browser session; a pending key's signature is refused at delivery. With a personal key, `deliver` signs whenever the key file carries a registered `key_id` (`--no-sign` to skip); a robot credential always signs.
- **Custody** (`latent_axol/custody.py`): `deliver` uploads into Latent storage when the server has it. Files of at most 64 MiB are copied into `~/.latent/staging/<upload id>/` at hash time and uploaded from that copy (the recorder rewrites the small meta files on every save); larger files are uploaded in place and their size and mtime are compared before and after the PUT ("the recorder changed <path> while it was uploading; deliver before the next take ends, or record one dataset per seed"). Each PUT goes through `http.client` with the file object as the body and an explicit `Content-Length`, the slot's headers verbatim and no credential; the upload is read again for fresh slots before every retry; `complete`; the upload is polled until `verified` with no fixed limit; an interrupt prints `--resume upl_...`. Upload create carries `episode_index`.
- **Seed cards** (`card.py`): `seeds` writes `~/.latent/cards/<episode>.svg`, one `<rect>` per dark module of the server's `card.modules`, 6.5 mm per module (`--a3`: 9 mm) with 4 modules of white around the code, the nonce, the episode id, the instruction and the seed window; the text card draws the QR in half blocks. A seed without `card` (an older server) gets the text only and the warning that seed-in-frame cannot pass.
- **Sidecars**: `calib` writes `meta/latent_calibration.json` (6.5) from a factory `SN<serial>.conf`, or with `--from-pyzed` from the factory file the ZED SDK keeps for the attached stereo camera's serial (pyzed imported only inside the function). The values are the raw ones (`rectified: false`, the Axol records its eyes unrectified) and include each eye's distortion and the pair's `rotation_rodrigues` and `translation_m`, so the checker can rectify the head camera for the task review's depth (docs/task-review-contract.md 6.6); a ZED X One file (`CAM_<resolution>`, `DISTO`) gives a mono camera's; `profile` prints a `CellProfile` draft; `latent_axol/imu.py` holds the IMU CSV format (`write_imu_csv`), which the recorder will fill once that integration exists. The file set of an episode includes `meta/latent_calibration.json`, `meta/latent_imu/episode_<index>/*.csv` and `meta/latent_robot.urdf` when present; the edited sidecars stay out.
- **Paths**: every path is checked against p2p3 6.6 before hashing (`paths.check_manifest_path`, `check_path_set`), and a refused path is named with its rule.
- **Duration**: the episode row's `length / fps` whenever the row exists; a typed `--duration` or a sidecar value still wins as before, with a warning when it differs from the row. A duration from file times is never sent when the row exists.

## Not built yet on the server

The robot side is complete against the fake server in `tests/fake_cells.py`, which is the reference until the server build of section 8 is deployed. The server library for these tasks (lib/market-cells.ts, market-cellkeys.ts, market-custody.ts and the delivery checks) is built in the verification branch; its routes, pages and install artifacts land with the server-surfaces build, so until that build is deployed a robot cannot pair with latentenergy.com. Missing on a deployed server today:

- the pairing routes (`/api/v1/cells/pair/start`, `/poll`), enrollment tokens and the `/pair` approval page (F tasks 5 to 9);
- the cell routes (`cells/me`, `cells/me/orders`, `cells/heartbeat`, `unpair`, `credential`, `keys`), the request-proof check in `requireApiKey` and `Latent-Server-Time` on every v1 answer (F tasks 4 and 6);
- `replace_seed_id` and seed release, the per-cell seed limit and order fencing (F tasks 11 and 12);
- the signature lift of section 5 and p2p3's body `signature`, required for robot credentials (F task 10; without it every robot delivery is refused); `manifest/preview` with `canonical` and `dropped_keys` (F task 13); `idempotency_key` on deliver (F task 15);
- the dedupe exemption for the shared meta files (`DEDUPE_EXEMPT`, p2p3; built in the server library, not deployed), without which a second take of one dataset staged with no save in between waits instead of delivering;
- the custody routes (`/api/v1/storage`, `/api/v1/uploads`, the local `PUT /api/storage/put/<token>`, the files route) and the cell-key routes (`/api/v1/cell-keys`): until they answer, `deliver` finds no storage and keeps the provider URL, and `key register` answers 404;
- a way for a robot whose `rotate-key` answer was lost to learn its new key id. Proposed for 3.8: a `cell_proof_invalid` answer carries `active_key_id` when the proof verifies under the cell's active key (only that key's holder can make one). `tests/fake_cells.py` has it behind `reveal_active_key_id`; without it the robot keeps the live key but asks for a re-pair;
- `public/robot/install.sh`, the wheel and `releases.json` (F task 16), and the owner's release key: `RELEASE_KEYS` in `latent_axol/release_keys.py` is empty, so robots accept only the compiled `SUPPORTED_SDK` and never install anything by themselves (8.1).

Unverified on hardware (risk 17): the start from the headset (observe mode logs first, then `am start` in the Quest browser, the reverse on 8790, WebXR after the start page, the session's stop event ending a real session, the contact trip narration from a real log), the recorder patch in real takes, the panel rendering `auto`, the brief and the buttons, the idle restart and the restart after a real Update, the maintenance run and `latent-maint-apply` under the Jetson's systemd, `SO_PEERCRED` between root and the operator, the LAN address from `getsockname`, and CPU and IO load while hashing and signing.

Where this build reads the contract one way among several: the socket's request line is `{"op": "<message>", "args": {...}}` because the `orders`, `ping`, `arm` and `last_order` requests carry a field named `op`; `status` also takes `show_seed_id` (the seed the card display shows); `take` answers `{ok, result: "queued" | "duplicate"}`; `arm` can answer `buffer_not_needed` and `refresh_skipped`, which the binder ignores.

## The manual flow (fallback)

With a personal API key (`export LATENT_API_KEY=vc_...`, created under Account settings with the order scope) or the robot credential, the commands below work without the hooks or the agent. Optional `~/.latent/latent.json` (or `./latent.json`; the Node CLI's file is accepted as is):

```json
{
  "origin": "https://latentenergy.com",
  "operator_id": "op-01",
  "robot_id": "axol-01",
  "station_id": "providence-104",
  "format": "lerobot_v3",
  "camera_count": 4,
  "storage_base": "https://files.example.com/axol"
}
```

Precedence for a personal key, highest first: `LATENT_*` environment variables, `~/.latent/latent.env`, `./latent.json`, `~/.latent/latent.json`. With the robot credential, `robot_id`, `station_id` and `storage_base` come from the cell and the origin from the credential. `latent-axol config` prints what was resolved, where each value came from and which credential is in use.

### The manual workflow, seed to delivery

#### 1. The order

The buyer places an order; you accept it on the order page. `latent-axol orders` shows it as `accepted` with open seeds 0.

#### 2. Seeds and cards

```
latent-axol seeds a1b2c3d4 3
```

POSTs `{count}` to `/api/v1/orders/<id>/seeds` and stores each seed, verbatim, at `~/.latent/seeds/<order id>/<episode id>.json` with an `index.json` of the open episode ids beside it. A seed is bound to the order, used once, and expires 72 hours after issue (`docs/verification.md:73`). For every seed the command prints an 80-column card and writes `~/.latent/cards/<episode id>.svg`:

```
================================================================================
                                  LATENT SEED
================================================================================

                     3f9a1c2e 7b8d4e6f 0a1b2c3d 4e5f6a7b

            episode a1b2c3d4-1   order a1b2c3d4   unit 1 of 3
Instruction Hand the cup to the customer
Objects     cup, saucer, tray
Issued      2026-10-21 14:03 EDT (18:03 UTC)
Expires     2026-10-24 14:03 EDT (18:03 UTC)

Place       in the overview camera's view, not under the arms, visible for the
            first 2 s of the take. Start recording inside the window above.
================================================================================
```

`latent-axol card <episode id>` reprints it; `--svg out.svg` writes the tablet version.

#### 3. Record

Put the card in the overview camera's view before you start. Record with the control panel or the headset as usual (`axol collect-data`, or Collect data in the panel). For an order whose step says the object ends standing upright, end the take with the side view of `docs/cells.md` section 7 (wrist camera beside the object, two taps of the empty gripper, hold 1 s, then rest and save; `docs/task-review-contract.md` 6.7): the taps are read from the recorded gripper log, so it needs nothing from the headset or the panel. Two things bind the take to the seed:

- **The task string.** Start the take with the task `"<instruction> [latent:<nonce>]"`. LeRobot copies `cfg.task` into every row (`almond_axol/cli/collect_data.py:1417`; `almond_axol/recording/record_proc.py:1834`) and into `meta/tasks.parquet` and the episode row's `tasks` column (`almond_axol/recording/dataset_browser.py:17-23`), so the nonce is inside the bytes that get hashed. The task may be renamed after the save from the panel (`PUT /api/datasets/episodes/{n}/task`, `almond_axol/serve/app.py:2820-2854`); do that before delivering, since the rename rewrites `tasks.parquet`, the episode's data parquet and its episodes row. `deliver` warns when the task carries a different nonce than the seed you name.
- **The card in frame.** Nothing in the SDK composites text into recorded video: the dataset branch is encoded on the relay from the raw grab (`docs/cli/collect-data.mdx:54`), and the headset only receives `state` and `episode` messages (`web/packages/axol-vr-client/src/AxolVRClient.tsx:202-226`). Phase 1 only needs the nonce in the manifest; phase 3 reads it in frame (`docs/verification.md:152`). Four groups of eight hex characters at 18 mm or more glyph height is the recommendation for the 960x600 SVGA dataset frame (`collect_data.py:784-788`); check one frame before the first paid order.

In the manual flow, one dataset per seed keeps a folder at one episode and `episode_index` 0. A shared dataset works too: `--episode N` names the episode and only its own files are hashed. (With the hooks, the panel names one dataset per order per session, `latent/<order 8>-<UTC time>`, decision 8 of the contract.)

Wait for the `Saved episode N` line before delivering. A fresh dataset with zero saved episodes is deleted on shutdown (`record_proc.py:3299-3318`), and `meta/info.json`, `meta/stats.json` and `meta/tasks.parquet` are rewritten on every later save (`record_proc.py:2776-2786`), so in a shared dataset deliver before the next take ends.

#### 4. Deliver

```
latent-axol deliver a1b2c3d4 ~/.cache/huggingface/lerobot/latent/a1b2c3d4-1 --seed a1b2c3d4-1 --success
```

What happens, in order:

1. The order is read (`GET /api/v1/orders/<id>`): state, unit type, terms, its seeds. The seed is taken from the server's list, else from `~/.latent/seeds`. Without `--seed`, the episode's sidecar names it, else the one open seed of the order.
2. The dataset is read: `meta/info.json` (fps, features, path templates), the episode's row in `meta/episodes/chunk-*/file-*.parquet` (pyarrow) or `meta/episodes.jsonl` (same keys, one object per line) when pyarrow is absent, and the sidecar `episode_<N>.latent.json` if there is one. The CLI says which backend it used.
3. The file set is the episode's own files exactly as `make_episode_durable` names them (`record_proc.py:2739-2766`): `data/chunk-XXX/file-XXX.parquet`, `meta/episodes/chunk-XXX/file-XXX.parquet`, one `videos/<camera>/chunk-XXX/file-XXX.mp4` per video feature, plus `meta/info.json`, `meta/stats.json` and `meta/tasks.parquet` when present. The sidecars (`episode_<N>.latent.json`, `latent/episodes/<N>.json`, `latent-episode.json`) are never hashed: they are edited after the save. `--all-files` walks the whole folder instead (hidden files, empty files, symlinks and the sidecars are left out; at most 500 files).
4. The pre-checks run before any byte is hashed: the seed window, the span order, the latency order, the cell ids, the order's unit type, `terms.required_streams` as path prefixes and `terms.min_active_s`.
5. Every path is checked against the path rules of p2p3 6.6 (a refused path is named with its rule), then every file is hashed with sha256 in 1 MiB reads; repeated bytes inside the set are refused with the path named. With Latent custody (the default when `GET /api/v1/storage` says it is enabled) small files are copied into the staging folder as they are hashed, and the upload runs before step 6; see "Verification phases 2 and 3" above.
6. The manifest is previewed and signed when a registered key file exists (`--no-sign` to skip), then POSTed to `/api/v1/orders/<id>/deliver` with `signature` in the body. The checks table prints (failures first on a refusal), then the buyer's decide-by date. A receipt with every hash is written to `~/.latent/deliveries/<order id>/<episode id>.json` and the local seed is marked delivered. Exit 1 on any failing check; nothing is created on the order and the seed stays open.

`--dry-run` prints the manifest and sends nothing; it works offline from the stored seed (`--unit-type` then says what the order is for). `--json` prints the raw server answer.

Video paths are `videos/observation.images.overhead_left/...` and so on (`almond_axol/lerobot/robot/config_axol.py:135-166`, the overhead records both eyes, a wrist records one under its plain name), so an order's `required_streams` must be a prefix the files carry, for example `videos/observation.images.overhead`. The server matches prefixes (`lib/market-manifest.ts:222-225`); when one is missing the CLI refuses before hashing and prints the prefixes the folder does have.

#### 5. Interventions (collect-dagger takes)

```
latent-axol deliver b2c3d4e5 <dataset root> --seed b2c3d4e5-1 --failure grasp_miss --latency 60:120 --policy pi05-cup-v7 --checkpoint /path/to/checkpoint
```

The takeover span comes from the dataset: `collect-dagger` adds a per-frame bool feature `intervention` (`almond_axol/cli/collect_dagger.py:152`) that the recorder writes on every row (`record_proc.py:1836`, `2520`), True while the operator drives (`collect_dagger.py:819`). `deliver` reads the episode's rows (pyarrow) or the sidecar's `intervention.flags`, run-length encodes them, merges takeovers separated by at most `--merge-gap` frames (default 15, half a second at 30 fps, the order of the SDK's 0.5 s `disengage_timeout`, `almond_axol/teleop/config.py:438-450`), drops spans shorter than `--min-span` frames (default 3), and delivers the longest span (`--span k` picks another). It prints the span and the others it found. Dataset time is rows / fps (`collect_dagger.py:580-583`), so a span that runs to the last row ends exactly at `duration_s`.

Known caveat printed with every span: the frozen gap between the grip press and the takeover is not recorded (`collect_dagger.py:17-23`), so a rubric like "takeover within 2 s of the policy stopping" cannot be judged from the span alone.

`--takeover a:b` supplies the span yourself when no flags exist (a dataset without the feature, or no pyarrow and no sidecar); the output then says "span supplied by operator". A failure class is required (`grasp_miss`, `misalignment`, `slip`, `perception_error`, `out_of_spec`, `other`). The episode is marked eval; `--checkpoint` is kept only for eval episodes (`docs/verification.md:101`).

The SDK measures no input-to-actuation latency for arm teleop (no wall clock in `collect_dagger.py`; `VRFrame.t_host` is consumed by `teleop_vr.py:1211-1219` and, on Mantis datasets only, written per row as `observation.pose_lag`, a pose-to-image lag, `record_proc.py:273, 1826`; `cli/mantis_latency.py` measures that same quantity with an ArUco sweep, which is not the figure the 300 ms rule is about), so `--latency p50:p99` is typed for now and the CLI says the 300 ms check (`docs/verification.md:109`) runs against that number.

#### 6. Corrections

```
latent-axol deliver c3d4e5f6 <dataset root> --seed c3d4e5f6-1 --failed-log a1b2c3d4-2 --policy pi05-cup-v7
```

#### 7. Status

`latent-axol status <order>` lists units, their check status, decide-by dates and reject codes, plus the open seeds and the next step.

### Where the episode fields come from

First source with a value wins, the same order as the Node CLI (`tools/latent-cli/README.md`, "Where the episode fields come from"):

| field | 1. flag | 2. sidecar `episode_<N>.latent.json` | 3. the dataset | 4. `latent.json` | 5. files |
|---|---|---|---|---|---|
| episode_id | | `episode_id` | | | seed's episode id |
| episode_index | `--episode` | `episode_index` | the row's `episode_index` | | 0 |
| operator_id, robot_id, station_id | `--operator --robot --station` | same names | | same names | error: "the cell is not named" |
| start_timestamp | `--start` | `start_timestamp` | (not in the dataset: its `timestamp` column is `frame_index / fps`, `record_proc.py:1175`) | | earliest file mtime, flagged "from file times" |
| duration_s | `--duration` | `duration_s` | row `length / fps` (`dataset_browser.py:236, 258-259`), else `total_frames / fps` for one episode | | span of file times, flagged |
| camera_count | `--cameras` | `camera_count` | video and image features of `info.json` | `camera_count` | error |
| is_eval_episode | `--eval`, any takeover | `is_eval_episode`, `op` collect-dagger or run-policy | | | false |
| checkpoint_path, policy_version | `--checkpoint --policy` | same names | | | "" |
| success | `--success`, `--failed` | `success` | | | null |
| intervention | `--takeover --failure` | `intervention.flags` or `spans_dataset_s`, `failure_class` | the `intervention` column | | |
| correction | `--failed-log --policy` | `correction` | | | |
| latency | `--latency` | `latency` | | | omitted |
| format | `--format` | `format` | `lerobot_v3` when `codebase_version` starts with v3 | `format` | other |
| storage_uri | `--storage` | `storage_uri` | | `storage_base/<folder>` | "" (custody warning) |

The sidecar may be flat or hold the fields under `episode`, like the Node CLI's `latent-episode.json`, which is also read for a one-episode folder. `latent/episodes/<N>.json` is read too. When both the sidecar's `spans_dataset_s` and the column exist, a disagreement over one frame is printed and the column wins: it is what the buyer can recompute from the bytes.

Sidecars (`*.latent.json`, `latent-episode.json`, `latent.json`, `latent-manifest.json`) are never in `files[]`: they are notes that may change after the save (success, failure class), which would change their hash. The nonce reaches the hashed bytes through the task string, not through the sidecar.


## The SDK facts this rests on, and where the design and the facts differ

- Episode control objects are the hook for a recorder-side sidecar writer: `collect-data` takes `control` with `note_dataset`, `note_ready`, `note_recording`, `note_saved`, `note_rejected` (`almond_axol/cli/collect_data.py:1036-1070`, `1073-1211`, called at `1426-1430`, `2402`, `2802-2809`); `collect-dagger` and `run-policy` take `_QueuePolicyControl` (`collect_dagger.py:1197-1201`, `2202`; `run_policy.py:483-572`). The serve layer's registry replaces a built-in command by id (`almond_axol/serve/__init__.py:12-28`; `serve/commands.py:970-981`), with `per_run_fields`, `field_suggestions` and `strict_fields` for a seed picker (`commands.py:94-99`, `190-216`). The panel renders a brief card from `snapshot()` (`web/app/src/lib/supervisor.ts:776-790`; `operation-panel.tsx:980`). The hooks of this package install exactly that control (see "The control panel hooks").
- The VR frame callback on the recording path (collect-data, collect-dagger) is `almond_axol/lerobot/teleop/teleop_vr.py:345-346` (`self._vr_server = VRServer(...)`, `set_on_frame(self._on_vr_frame)`), as the design said; `almond_axol/teleop/teleop.py:207-208` is the plain `axol teleop` path only. `set_on_frame` is `almond_axol/vr/server.py:400-402` and `t_host` is stamped at `almond_axol/vr/interp.py:367-371`.
- The design's `docs/verification.md` line numbers are off by a few: nonce format is line 73, `seed_time` 100, `pi_columns` 101, `intervention_span` 104, `streams` 107, `latency` 108, `latency_hard` 109, `custody` 110, seed in frame 152.
- The design's `--stream-alias` (rewriting a path to `overview/videos/...` in the manifest only) is not implemented: the manifest path is what the buyer downloads and what phase 2 verifies against bytes, so a rewritten path would not match the folder. The CLI refuses a missing required stream before hashing and names the prefixes the folder has instead.
- `list_zed_devices` runs the enumeration in a helper process (`zed/devices.py:52-54`); `default_robot_id` calls `list_zed_devices_inproc` (`zed/devices.py:49-75`) instead, only when asked with `--probe-cameras`.
- A refused manifest answers 400 `manifest_invalid` (`app/api/v1/orders/[id]/deliver/route.ts:12-13`; `lib/market-db.ts:1190`), not 422; the CLI keys on the error code, not the status.
- Nothing may be written into a live dataset tree (decision 9 of `docs/robot-connect-contract.md`). An earlier version of this README said `episode_<N>.latent.json` and `latent/episodes/<N>.json` were safe to write into the dataset root; that is withdrawn. `restore_dataset_ownership` runs after every save (`collect_data.py:2807`, `collect_dagger.py:2199`) and `secure_chown_tree` raises `UnsafeStatePathError` on any entry created, renamed or linked while it walks the tree (`utils/state_files.py:26`, `467-516`); the restore then stops and only logs, leaving later root-owned 0600 mp4 files unreadable (`recording/ownership.py:6-9`). The agent therefore copies the three shared meta files out under the SDK's lock and keeps the take file in its own take folder. Sidecars that already exist are still read by the manual `deliver`.
- Camera count: the SDK's browser counts `dtype == "video"` features only (`dataset_browser.py:208-213`); this CLI counts video and image features, like the Node CLI (`latent.mjs` `lerobotHints`), so the two CLIs agree on the same folder.


## Tests

```
python3 -m unittest discover -s tools/latent-axol/tests -t tools/latent-axol
```

Plain `unittest`, Python 3.9, no network beyond 127.0.0.1, no child processes, no `almond_axol` import. `tests/fake_custody.py` adds the custody, preview, cell-key and files routes to the fake server (`tests/fixtures.py`), with the 7.6 dedupe exemption and episode scope behind `enable_storage()`; `test_custody.py` and `test_cell_tools.py` cover uploads, resume, the staging copy, the mid-upload guard, signed deliveries, fetch, the A.9 path vectors, the golden seed cards (`tests/vectors/seed_card_golden.json`, copied from p2p3 A.6), keys, calib, profile, IMU and the agent's `uploading` stage. `tests/fakes.py` holds SDK stand-ins with the real 0.2.25 signatures and a fake control panel; `tests/fake_cells.py` is the fake Latent server (pairing with proofs, re-mint, adoption, request proofs, revocation, seeds with `replace_seed_id`, preview, signature-required deliveries, redirects, static release files). The vectors in `tests/vectors/` (RFC 8032, the refusal cases, the pairing and request proofs, the release list, the QR symbols) are reproduced exactly. `test_people.py` covers the people scan with a fake `latent_checker.people` put into `sys.modules` (no OpenCV, no models). `test_agent_e2e.py` runs the full take with zero typed input: fake SDK, real hooks, real agent threads, the socket and the fake server. With the scratch environment that has pyarrow and OpenCV, the same command also runs the parquet smoke test and decodes every QR symbol with `cv2.QRCodeDetector`.

## Reference

The manifest is `ManifestInput` in `lib/market-types.ts`; the checks are in `docs/verification.md`; phases 2 and 3 are `docs/verification-p2p3-contract.md`; robot connect is `docs/robot-connect-contract.md`. The Node CLI is `tools/latent-cli`.
