Every agent build the fleet can update to, newest first. Download · Docs
* `New:` `tether.status` now carries an ordering token — a random `boot_token` minted once per service start, plus a `seq` that only ever increases within that run. Control uses it to reject a QoS-1 redelivery of an earlier report even when that redelivery arrives *after* the report that superseded it, which arrival-time ordering alone cannot do (control#42). * Deliberately not `uptime_s`: that is *service* uptime and resets whenever TetherService restarts, so it cannot order anything across the restart it is most needed for. And deliberately not the board clock, which can be wrong or jump. * `seq` starts at 1, never 0, so a "missing field defaulted to zero" bug on a reader could never masquerade as a real sequence number. * An older control ignores both fields; a newer control seeing neither falls back to exactly the ordering it used before. Nothing here can make an old report look newer.
* `New:` the setup screen now tells a staff member what to do when the board will not come up. It offered them a serial, three English-labelled fields and a raw event log — **zero on-screen escape hatches**, while the words that would have helped existed only on `/guide`, in a browser they had already closed. A low-emphasis line now appears whenever the connection is anything other than proven-connected, and disappears once it is. * The hint is keyed to the **labels** the screen shows (`Холбогдсон` / `Холбогдоогүй`), not to a colour, and is the `/guide` sentence verbatim so the page and the machine say the same thing. Text naming a colour would not survive the next person who changes what the colours mean — which is exactly how the previous wording went wrong. * `Fix:` the field labels `frps server` / `frps port` / `frps token` were the only English on an otherwise fully Mongolian screen, and they marked precisely the three fields the guide tells staff **not** to touch. Now `Сервер` / `Порт` / `Токен`. Nothing is removed, no stored value or field key changes.
* `New:` the watchdog's own restart narrative now reaches the fleet instead of dying in a root-owned log nobody reads. `restarting (stale=N fails=N)` was written only to `watchdog.log`, which no Java code opens — so the most common self-healing loop on the board was invisible everywhere, while the two rarer ones (hook wipe, update rollback) had both been deliberately bridged. A `watchdog.restarted` event and a `watchdog_restarted` field in `tether.status` now carry it. * Worth knowing why this is a running total rather than a take-and-clear notice like `Updater.takeRollbackNotice()`: the thing the watchdog most often restarts is Java itself, so Java is frequently dead across several restarts in a row. A notice that held only the last one and cleared on read would report at most one restart per Java lifetime and drop the rest of a burst silently. The counter is written by exactly one writer and read by exactly one reader, so the read-modify-write race that a shared boot-scoped file has caused twice in this codebase cannot happen to it. * Known limit, stated rather than guessed around: a watchdog script upgrade resets the counter without a reboot, and restarts since that reset are dropped rather than invented.
* `Fix:` the setup screen turned green from local configuration, not from connectivity. `isProvisioned()` is written synchronously with `commit()` the instant the button is pressed, so the screen went green within about 1.5s of the tap and stayed green whether or not the board ever reached the broker — and `/guide` told the staff member standing at the machine that green means connected. The colour now keys on a `LinkState` written only from Paho's own callbacks, never from a preference, and the screen shows three states: not configured, connecting (amber), connected (green). Deliberately not keyed on `hasMqttCache()`, which is cached credentials — still local state written before any connection succeeds, so it would have swapped one local-state proxy for another. * Worth knowing what green now does and does not mean: it means a CONNACK completed with the broker, so control can reach the board. It does **not** mean the frp relay path works — frpc dialing frps is a separate mechanism, and a board can hold a live MQTT session while it cannot reach the relay. The `/guide` copy is scoped to "connected to our management server" for exactly that reason.
* `Fix:` the agent's crash reporter had never reported a crash. Its Bugsink DSN was `http://…@103.168.56.233:8000/1` while the manifest sets `android:usesCleartextTraffic="false"`, so Android dropped every POST silently — no error, no log. The only events the project had ever held were synthetic curl tests from a laptop, which is exactly why it went unnoticed. Now `https://…@storage.mtm.mn/bugsink/1`, the same nginx path `com.vms.polaris` was moved to on 2026-09-10 (`bugsink.mtm.mn` has no DNS record; an NSC `<domain>` exception cannot help either, because Android does not match IP literals in domain-config). Verified before shipping: `POST /bugsink/api/1/envelope/` answers 200. * Worth knowing what this does and does not buy: it reports crashes the JVM throws. It does not report a SIGKILL from the low-memory killer, which is how a board was lost on 2026-09-16 — resource exhaustion is invisible to a crash reporter by construction.
* `Fix:` 1.2.79's `AdbPort.effective()` read the override off disk on every call, which put a fresh `su` — and on this ROM an ~8 MB helper JVM — on `AdbGate.currentClients()` and on the 60s heartbeat tick. That is the same one-su-per-call pattern that filled a board's memory and got the vending app killed on 2026-09-16, reintroduced by the fix for a different bug in the same file. The port is now an in-memory value, read from disk once per service start (`AdbPort.load()`, before anything consults it) and updated in place when a session claims or releases it. 1.2.79 was never published, so no board ran it.
* `Fix:` an operator could not take a board from a laptop that was squatting on adbd's one slot with a direct `adb connect <board>:5555`, however many times they asked. Control re-sends the tunnel command for every request, so every attempt after the first reused the running session and took `Tunnel.open`'s early return — which deliberately skips takeover, because the client it would otherwise drop is normally the operator's own connection arriving through the tunnel. That reasoning is right about loopback and wrong about everything else: a repeat now evicts a NETWORK client and still never touches a loopback one. Cost an engineer an hour on bench .69 on 2026-09-16, retrying something that could not work. * `Fix:` one takeover was not enough to win the slot. Measured on .222 the same day: `adb.slot_taken took over from=192.168.88.120 -> restarted` fired correctly, and the displaced host's adb server — which retries continuously once told to connect — was back in ESTABLISHED on a new source port milliseconds later, before the tunnel's own frpc had dialled, leaving the asker with `device offline`. Because control re-sends the command several times per request, letting each re-send re-take the slot turns a coin toss against a client that never stops trying into a win for the person who actually asked for the machine.
* `Fix:` the leftover-helper reaper introduced in 1.2.76 took the same lock as `Root.available()`. Its `/proc` walk costs what the board's sickness costs — over 120 seconds measured on the thrashing board, against a few on a healthy one — so on the shared class monitor it would have blocked the heartbeat, command dispatch and the watchdog's persistence check for that whole time, every cycle, during exactly the low-memory crisis it exists to end. A stalled heartbeat is what makes the watchdog restart the service, and that restart re-runs the service-start burst; the tidier lock would have deepened the failure it was reaping for. The reaper now has its own monitor. * `Fix:` the log shipper sent `logcat --pid=` to every board, including the one ROM without it. API 23's logcat does not reject an unknown flag — it prints its whole usage block to stdout and exits 0 — so that board had been shipping logcat's man page to Loki, diffed and parsed as though it were the app's own output. It now gates on `Capabilities.logcatPidFlag()` and filters the full-buffer dump locally through `LogcatSession.onlyPid`, which is what the live viewer's fallback has always done. Pre-existing, not introduced by 1.2.76, and invisible on the four snbc boards.
* `Fix:` log shipping could starve the board's own vending app to death. On this ROM `su` spawns a full detached JVM helper (~8 MB RSS) for every invocation by an app uid, and `LogShipper` was making 6 of them a second (three `su` calls, twice a second, for two packages) even at idle. Measured on bench board .69: at boot, while the board was already slow, 230 of these helpers accumulated within 3 minutes — ~1 GB RAM plus all of zram swap — and the low-memory killer took `com.vms.polaris`, the app the board exists to run, down with them, repeatedly, as it restarted into the same storm. `Root.available()` is now cached (asymmetrically: forever once true, 60s once false); `Root.run` merges stdout/stderr via a single `ProcessBuilder` stream instead of draining one pipe before touching the other, which could also hang a thread forever on a large stderr write; pid resolution and the logcat dump are now one root round trip (`Root.pidAndLogcat`) instead of two, used by both `LogShipper` and the live logcat viewer; and `LogShipper`'s poll interval no longer outruns its own flush interval (2s -> 5s, matching `FLUSH_MS`). None of that makes the failure mode impossible by itself, so `LogShipper` also checks `/proc/meminfo` before every poll cycle and stands down — logging once on the transition, not once per poll — when available memory drops under 12% of total (floored at 96 MB), and reaps any leftover superuser helpers belonging to this app's own uid while it does, two confirmed sightings apart so a helper mid-broadcast is never touched. That reap also runs on a fixed 5-minute schedule regardless of memory, not only when the guard trips: a follow-up check on .69 found these helpers do not retire on their own — pids from the original boot were still `State: S (sleeping)`, `VmRSS: 0 kB` an hour later, and the pile had rebuilt to MemAvailable 120 MB within an hour of being cleared by hand. At RSS 0 a pile like that is invisible to the memory guard until it is already large enough to make its own scan slow, so waiting for the guard alone would always be reacting late.
* `Fix:` on a ROM that ships no `/system/bin/install-recovery.sh` of its own, the boot hook was never installed — and the log said "boot hook already present" every time. `grep -c` on a file that does not exist prints nothing and exits 2, and the guard read that empty string as "not zero, so leave it alone", which put the one line that creates the missing file inside a branch that could never be entered. Such a board carried no boot hook at all, so a force-stopped package plus a reboot — the exact case the hook exists for, because Android delivers no `BOOT_COMPLETED` to a stopped package — needed a site visit. Found on .71, a UniWin M190 whose init.rc has the `flash_recovery` service but no script for it to run. An unreadable count is now zero occurrences, so the hook gets installed. * `Fix:` boards report only the boot hooks they can actually carry. `rc` (`/system/etc/init/vms-tether.rc`) needs the ROM's init.rc to import `/system/etc/init`, which Android 7 added; on an API 23 board the agent correctly declines to write it but reported it anyway, so the console showed a permanent "rc missing" warning for a mechanism that does not exist there — and, worse, `HookState.evaluate` kept handing back a non-empty reinstall list, remounting /system read-write on every pass to reinstall a hook that read missing again immediately. The key is now emitted only where that import is present. `recovery` stays unconditional: where init.rc has no `flash_recovery` either, the board really does have no boot hook and the badge is the honest report of it.
* `Upgrade:` the tunnel TTL ceiling is raised from twelve hours to twenty-four (`Tunnel.MAX_TTL_SECONDS`). Twelve hours covered a working day; it did not cover an overnight maintenance window, and the only workaround was a second grant partway through the job. Control's ceiling is rising to match in a companion change. * `New:` this board now reports the ceiling it will actually enforce as `tunnel_max_ttl_s` in `Capabilities.toJson()`, sourced from `Tunnel.MAX_TTL_SECONDS` rather than retyped, so the two can never silently disagree the way they did once before: control granted an admin 86400s while this board clamped to 21600, and the operator was told a day and got six hours with neither side reporting the other's number. Control can now clamp to the number this board actually reports instead of guessing it — but only once a board is running this build; an older agent still clamps to twelve hours and reports nothing, so control's new ceiling means nothing to it until the fleet is upgraded.
* `Fix:` comments that still described the closed-by-default adb policy reverted in 1.2.69. The `ADB_GATE` field's javadoc claimed the watchdog "closes the port on Tether's behalf... and writes a fresh `unreachable` grant"; the script thirty lines below says the opposite, correctly. Another named `AdbGate.reconcile()`, which no longer exists. A test docblock still described the automatic unreachable grant. The logic was right in every case — only the prose lagged, which is the worst way for it to be wrong, because the next reader trusts it. * `Upgrade:` `portVia()` asks for both properties in **one** `su` shell-out instead of two. It runs on every status report on every board, and the process is the expensive part, not the getprops. The parse is now the pure `portViaFrom()`, and `portViaCmd()` is exposed so the ORDER can be pinned: the two values are read positionally, so swapping the getprops would mislabel every board in the fleet — service reported as persist and back — without failing anything. Found by mutating exactly that and watching nothing go red.
* `New:` **adb is now auto-takeover: asking for it always takes it.** Whoever had it loses it, and gets it back the same way — by asking. Control and the CLI are changing separately to grant rather than refuse; this is the board's half, and without it a newly granted session still could not attach while the previous holder's connection was alive. Measured on bench .71 and .69, 2026-09-14/15: while one client is live on adbd, a second cannot attach — with a competing session live, `adb connect` to the board failed outright, and with a LAN transport held, a tunnel attach failed with `adb connect failed: failed to connect to 127.0.0.1:<port>`. Dead sockets are harmless (four loopback `CLOSE_WAIT` entries coexisted with a live `ESTABLISHED` one); it is specifically a live client that excludes another. A brand-new `remote.tunnel` session now restarts adbd to take over from any other attached client before the operator's connection can land — a direct LAN `adb connect <board>:5555` is displaced just the same as a previous tunnel holder, since both show up the same way in `AdbGate.currentClients` — but only when there is actually another client and never for a repeat of the session already running (`Tunnel.tomlMatches`) — that path extends in place and must not disturb the operator's own connection. adb stays open by policy; only the stale connection goes. * `Fix:` **removed the `reason` field from `tether.status`'s `adb` block.** It read `off` next to `open: true` on every board in the fleet. The field made sense when a grant decided whether the port was open — `tunnel` meant "open because a session holds it", `off` meant "no grant, therefore closed" — but the port has been open by policy since 1.2.44 (only `staff_closed` closes it now), so grants no longer gate anything and the field had come to mean "no grant is live" while displaying a word that says "adb is off". Two different sentences wearing one label, described that way to the one person reading it. `open`, `serving`, `clients*`, `port_via`, `expires_in_s` and `by_claimed` already say everything this field was standing in for, so it was removed rather than left to mislead.
* `Fix:` **every `key=value` state file was read with a `sed` that does not work on Android 6.** The watchdog pidfile, the adb gate and the update lease were all parsed with `sed -n 's/^key=//p'` — correct POSIX, fine on the SNBC fleet ROM. Measured on bench .71 (UniWin M190, Allwinner, Android 6.0.1) on 2026-09-15: that toybox prints the matched line with the substitution **not applied**, so every value came back carrying its own key (`boot=<id>` where `<id>` was meant). Plain `sed 's/^key=//'` fails there too, and `awk` exists on neither ROM; `grep | cut` was measured working on both. All ten reads now go through one `keyOf()` helper. Consequences on every Android 6 board, none of them cosmetic: `isRunning()` compared `boot=<id>` against `<id>` and reported a **live** watchdog as dead (the health card's "Watchdog running" failing on a board that had TWO running); `ensureRunning()` made the same comparison and so **spawned another watchdog on every start**; the kill rung ran `kill "pid=6020"` and could never reap the duplicate; the adb-gate sweep never matched the boot id and dropped the grant record every pass; and the update lease's boot check — the rung that decides whether a new build is **rolled back** — never matched either. Script v18. * `Upgrade:` the liveness probe is `Watchdog.wdUpCmd()`, split out so the parsing it depends on can be pinned by a test. This method has now reported a live watchdog as dead twice, for two different parsing reasons; the second time no test could have caught it, because every test asserted on the script's text and the text was right — it was the interpreter that differed.
* `Upgrade:` **the last of the LAN-adb blocking feature is gone from the hello.** The `iptables` capability was probed through `su` on every board and shipped in every hello, but nothing has read it since the blocking failsafe was removed — control never referenced it, and neither did the console, CLI or TUI. It existed only to answer "can this board install the DROP rule", a question the fleet stopped asking when `0031_drop_lan_adb.sql` settled that adb-over-LAN is simply always reachable. Removed the field, the probe and the JSON key; capabilities are a free-form object on the wire, so a control that still has the old key stored just stops seeing it reported. `clearLegacyLanBlock()` stays — it is the feature's un-installer, not the feature, and it is what makes "the LAN is open" true on a board updating off a build that blocked it.
* `Fix:` **the port is open by design again; what expires is a CONNECTION.** 1.2.66 closed adb whenever no grant was live, on a reading of "adb must expire" that took the port for its subject. Wrong noun: a closed port makes a machine unrecoverable, and control migration `0031_drop_lan_adb.sql` already states the fleet's position — "adb-over-LAN is simply always reachable now". Reverted: `AdbGate.decidePort` returns Open with no grant, the watchdog no longer closes the port (script v17, it only sweeps a lapsed grant record), and the `unreachable` grant, `reconcile()` and the dead-agent debounce are gone with it — they existed only to make closing safe. `staff_closed` still outranks everything, so a site that wants adb shut can have it. * `New:` **a LAN adb connection is dropped after `LAN_CONN_TTL_SECONDS` (30 min).** This is the failure that actually happened, measured 2026-09-14: a direct-LAN transport from an operator's box sat attached to bench .69 for a day, and because adbd here serves one TCP client at a time, every `remote.tunnel` attach to that board was refused — the machine was unreachable through the tool built to reach it. Ages are tracked per peer in `AdbConnAges`, in the same boot-id + uptime form as the adb gate (no RTC on these boards: they boot at 1970 and jump decades when NTP answers). Only non-loopback peers are reaped — a loopback peer is frpc carrying a session tether already deadlines — and a reap is deferred while a tunnel is live, since the mechanism is an adbd restart and it would otherwise drop the operator's own session. The port is never closed: adbd comes straight back listening. * `Fix:` the reap's deferral is **bounded** (`DEFER_PASSES_BEFORE_REAP`, 5 passes). Found on bench .71 during on-hardware verification: deferring for as long as a tunnel session was live meant the reaper stood down for exactly as long as somebody was working on the board — which is when a forgotten LAN transport does its damage, since adbd serves one TCP client at a time and the squatter is what blocks the next tunnel. A board in steady use would have reaped nothing. A live session now buys a grace period, not immunity; dropping the operator's adb connection is survivable because frpc keeps running and the CLI reconnects the transport itself. * `Fix:` **status could report `adb open=false` while adbd was serving.** `isOpen` consulted `service.adb.tcp.port`, and on bench .71 that property is empty while `persist.adb.tcp.port=5555` — status said closed while a 21 MB APK was being installed over that very connection. Reachability is now exactly "something answers on the port"; `isPortConfigured` accepts either property. * `New:` `port_via` (`service`/`persist`/`both`/`none`) and `clients_lan_oldest_s` in `tether.status` and the tunnel verdict. `port_via` answers "is this board default-open" for the whole fleet without tunnelling into each one — a guess had already got it wrong, calling .222 persist-only when it is like .69. `clients_lan_oldest_s` is **omitted rather than 0** when nothing is attached: "nobody there" and "arrived a second ago" are different facts. * `Upgrade:` `AdbGate.keepOpen` asks whether adbd is *answering* and opens the port only when it is not — never restarting a daemon that already answers, which is how a UniWin M190 was lost on 2026-09-12. Measured across the fleet: `.69` service only, `.222` both properties, `.71` persist only and still default-closed because that ROM never copies persist into the property adbd reads. Reasoning from the properties would have called .71 open.
* `Fix:` **OTA self-update, and its rollback, could not install on Android 6 (API 23).** Measured on bench .71, device 686 (UniWin M190, Android 6.0.1), 2026-09-14: `tether update 686` pushed 1.2.67, the agent downloaded, verified and staged it correctly, and `pm install` then failed with `Failure [INSTALL_FAILED_INVALID_URI]` — the board stayed on 1.2.65 with no rollback needed, because nothing had installed. The staged APK (and the retained rollback APK, `previous.apk`) lived inside `Root.DIR` (`/data/local/tmp/vms-tether`, `chmod 700` root-owned). Android 7+ opens the install path as the calling process (root), which can traverse a 700 directory it owns; on API 23, `PackageManagerService` opens it as `system` instead, which cannot traverse a 700 directory it does not own — so a perfectly good 644 file inside read as an invalid URI. `chmod 755` on `Root.DIR` was not an option: `Root.ensureDir()` reapplies 700 on every update, and the directory holds the frp session secret and the adb gate. Both the installer script's forward install and its own rollback rung, and the watchdog's separate reboot-survivor rollback backstop, now copy the APK to `/data/local/tmp/tether-install.apk` — outside `Root.DIR`, on a path `system` can reach — immediately before each `pm install`, `chmod 644` it there, and remove it immediately after, win or lose, so a world-readable copy exists only for the few seconds the install actually runs.
* `New:` **the board reports who is actually attached to adbd** — `clients`, `clients_lan` and `client_peers` (capped at 8) on `tether.status`'s `adb` object, and `clients_lan`/`client_peers` on the `tether.tunnel` verdict. Measured on bench .71 on 2026-09-14: a second live adb transport to the same board makes `tether adb` fail with `adb connect failed: failed to connect to 127.0.0.1:<port>`, and until now the CLI could only advise the operator to go look at `adb devices` — a client-side guess about a fact the board itself knows. A stale `offline` entry, by contrast, does not block anything (it was present through every successful call in that same test), so the fact worth publishing is live peers, not the grant bookkeeping `AdbGate` already reports. `AdbClients` is a pure parser of `/proc/net/tcp`/`tcp6`, read over root in one call alongside the existing grant read — same discipline as `AdbGrants`, unit-tested because a parsing slip here is a wrong answer about who holds a root shell. Deliberately a different question from `AdbHealth`, which answers "is adbd well" and still does not read `/proc/net/tcp` for that — see both classes' headers.
* `Fix:` re-release of 1.2.64. Its publish reached the OTA channel and then failed uploading to the download page (the runner could no longer pull `minio/mc` from Docker Hub), and because the job treats an already-published versionCode as "nothing to do" and exits before the upload, re-running it could not repair the split. Boards could take the release while a technician standing at a machine could not download it. Same contents as 1.2.64.
Commissioning the first non-SNBC board (UniWin M190, Allwinner, Android 6.0.1 / API 23) surfaced five defects. All are verified on that board and on an SNBC board; nothing here changes SNBC behaviour. * `Fix:` **the agent could hard-reset a board every time it started.** `pidof` on some ROMs prints EVERY pid on the system for a name that does not exist, and exits 0 — measured, 130 pids including 1. The watchdog fed that straight to `kill` as root (`kill -TERM $(pidof tetherwd)`, and `kill -9 $(pidof <pkg>)` in the restart rung), so the board SIGTERMed init and reset. `tetherwd` is the removed legacy daemon, so it never exists and the path was always taken. Pids are now validated against `/proc/<pid>/cmdline` (exact `argv[0]`) before anything signals them. * `Fix:` the startup pass no longer restarts `adbd` when it is already answering. Where the property did not read back as 5555 it restarted anyway, and on one ROM adbd returned USB-only — a reachable board became permanently unreachable at the moment the agent meant to protect it. * `Fix:` the watchdog no longer closes adb on grant expiry. It contradicted `AdbGate.apply()`, which owns that policy and keeps adb open by default, and it ran every 60s so the shell always won. * `Fix:` per-app logs on ROMs without `logcat --pid=` (Android 7.0+). On API 23 logcat prints its usage block to stdout and exits 0, so the console streamed logcat's man page instead of the app's logs. The flag is probed; without it, `-v threadtime` carries the pid as a column and is filtered. * `Fix:` screen capture no longer needs `base64`, which is absent on some ROMs — `screencap` wrote a good PNG and the read-back produced nothing. The frame is captured into the app's own directory and read directly. * `Fix:` log shipping applies the bundled ISRG Root X1 trust. Android 6's store lacks that root, so every push failed with `CertPathValidatorException` while MQTT connected fine to the same host.
* `Fix:` `tether.screen` now actually captures. The frame is read back from `screencap` through root as base64, because the app process cannot traverse the 700 root-owned working directory the file is written into — the earlier build's direct file read failed with permission denied and acked `failed:capture`.
* `New:` a `tether.screen` command captures the board's screen with root `screencap`, encodes it to WebP (downscaled to a 1280px longest edge), and uploads the frame to control — the console's new Live screen panel shows what is on the machine right now. Change-detection skips re-uploading an unchanged frame, so an idle screen costs a capture and a hash, not a repeat upload.
* `New:` a command reports its progress as discrete lifecycle stages over a new `cmd.progress` event, so the console shows what a long command is doing instead of a single spinner — `app.update` reports preparing → downloading → installing → verifying (and rolling back on the rollback path); maintenance, the diagnostics (`status`/`apps`/`logs`), tunnel, and the agent self-update report their stages too. Purely additive — the terminal `cmd.ack` is unchanged.
* `Fix:` a fast maintenance enter→exit could leave the board stuck on the cover — removed the delayed re-show that could fire after exit (the cover already reclaims the front on its own). * `Fix:` a crash-looping FRESH app install, or a rollback whose own build also fails to boot, no longer reads as success — the agent keeps its full-screen lock up (never the launcher) and acks `installed_dark` / `rolled_back_dark` so the operator knows the board needs hands-on recovery. * `Fix:` `MaintenanceActivity` bails if it was dismissed before its async launch ran, and maintenance `EXIT` is now an ordered broadcast so the agent only returns the board to its selling screen when the vending app confirms it left (a `BUSY` refusal to protect a live sale is respected).
* `Fix:` exiting maintenance now reliably returns to the vending app's selling screen. The app's own return-to-home does not surface while it is backgrounded behind tether's cover, so on exit the agent brings the vending app's launcher (selling) screen to the front, so its maintenance screen no longer lingers.
* `Fix:` the maintenance cover now reliably stays on top of the vending app's own maintenance screen. If that screen lands over ours (a launch-order race), our cover reclaims the front while maintenance is active, so only one screen — tether's — is ever visible, instead of the two stacking.
* `Fix:` maintenance no longer stacks two screens. `ENTER_MAINTENANCE` now carries `suppress_ui=true`, asking a cooperating vending app to stop selling without drawing its own maintenance screen, so only tether's screen shows. On exit, the vending app is returned to its selling screen before tether's cover is lifted, so no maintenance screen lingers or flashes. (Needs the matching vending-app change to fully suppress its UI; older builds are unaffected.)
* `Upgrade:` the maintenance screen is now full-screen and illustration-led — a large wrench illustration (a grape design-system icon, bundled in assets) over the console's bold Zona Pro title, replacing the earlier status-pill layout.
* `Upgrade:` the maintenance screen now uses the web console's own Zona Pro typeface (bundled in assets) and its flat, typographic layout — the bold TETHER wordmark with the console's tight tracking, a tinted status pill, and a large near-black title on a clean white ground — instead of the generic icon-card look.
* `Upgrade:` the maintenance screen now matches the tether web console — a light off-white ground with a single centered white card, TechPartners-blue brand accents, and the console's neutral type scale — instead of the earlier dark panel.
* `New:` tether-owned full-screen maintenance screen. `tether.maintenance enter` now draws the agent's own branded "under maintenance" cover (like the updating screen) over the top of the vending app's maintenance state, so a board pulled out of service shows one consistent tether face instead of the vending app's own screen. Shown only when the board is actually taken out of service (a clean `OK`, or `force`), so a live sale is never hidden behind it; taken down on `exit`. * `Fix:` `app.update` now reports WHY it deferred — `deferred:busy` (a customer is mid-sale) versus `deferred:unconfirmed` (a guard-carrying app did not confirm it is idle in time) — instead of flattening both to `busy`.
* `New:` standalone `tether.maintenance` enter/exit command (sale-safe, reuses the ENTER/EXIT_MAINTENANCE protocol). Previously `Maintenance.enter` was only ever called from inside `app.update`, tying maintenance mode to an install; an operator can now put a board into maintenance and take it back out on its own.
* `New:` report per-app `debuggable` (release vs debug) and `home` (main app) flags for the console. `tether.apps` now marks each installed package with whether it was built debuggable (`ApplicationInfo.FLAG_DEBUGGABLE`) and whether it currently answers the HOME intent, alongside the existing `system`/`integrated` flags.
* `Fix:` drop SNBC vendor driver (`driver_root`) and Android framework (`Choreographer`) noise from log shipping. Across the fleet, `driver_root` (the SNBC vending-machine controller's own logcat) and `Choreographer` (Android's frame-skip warnings) together accounted for roughly 83% of a board's shipped log volume — neither is code this repo or the polaris app controls (confirmed with the polaris team), so it can only be dropped here, at the agent's capture layer. Both are now in the built-in denylist alongside the 1.2.49 media-framework tags.
* `Fix:` drop Android media-framework noise tags from log shipping. On a real
board most shipped lines (and every `ACodec` "ERROR" line) were benign
codec/GC chatter, not real faults, and were flooding the log store. A
built-in denylist (`ACodec`, `MediaCodec`, `MediaCodecList`,
`MediaCodecInfo`, `OMXClient`, any `OMX`-prefixed component, `ExoPlayerImpl`,
`ExoPlayerImplInternal`, `AudioTrack`, `art`) is now applied to each parsed
line before it is buffered, so a dropped line never consumes buffer space or
bandwidth; `driver_root`, `Polaris`, `StateReporter` and the app's own tags
are untouched. Remotely tunable via `tether.logs.denytags {tags:[...]}`,
which replaces the built-in denylist wholesale (persisted, like
`tether.logs.level`/`tether.logs.enabled`) — exact tag match, or a trailing
`*` for a prefix.
* `New:` persistent fleet log shipping to the org's central Loki
(`logs.techpartners.asia`, open/unauthenticated push). Always on — not
lease-gated like the live logcat viewer — for exactly `com.vms.tether` and
`com.vms.polaris`, shipping INFO and above by default. Reuses the
poll-and-diff logcat technique from 1.2.47 (now shared via `LogcatDiff`),
parses each line's level/tag/pid/message, batches and gzips them into
Loki's push JSON grouped by package/level (pid/tag folded into the line
body, never a stream label), and buffers on disk (bounded, drop-oldest,
persisted across a restart) with exponential backoff so a Loki outage can
never wedge the board. Remote-controllable over MQTT:
`tether.logs.level {level}` overrides the floor at runtime (DEBUG through
FATAL, persisted), `tether.logs.enabled {enabled}` is the kill-switch
(persisted). Both ack on `/evt` like every other command. Builds that
predate this ignore the new commands.
* `Fix:` live logcat now polls `logcat -d` and publishes the diff each second, because continuous `logcat --pid` streaming does not reliably tail live on the SNBC boards (it returned only stale backlog); dump mode is reliable, so viewers now get a dependable ~1s-latency live stream instead of a one-time snapshot.
* `New:` live per-app logcat streaming. The agent handles `tether.logcat.start` / `tether.logcat.stop`, running a root `logcat --pid` session for one package and publishing batched lines to `tether/<device>/logcat` (QoS 0). Rate-capped (drops are marked, never silent), lease-expiring (self-stops when no viewer renews, or at a 15-min ceiling, and auto-resumes on the next renew), and hardened against a hostile package name (rejected before use and shell-quoted at the `pidof` call). Builds that predate this ignore the new commands.
* `Fix:` a board updating from a build that still blocked adb on the LAN (≤ 1.2.43) kept the iptables DROP rule and its lease after 1.2.44 removed the feature — nothing was left to take them out, so the LAN stayed closed. The service now clears any leftover 5555 DROP rule and lease once on startup, so the LAN is actually reachable after the update. Idempotent and root-gated.
* `Upgrade:` the LAN-adb blocking feature is removed. The agent no longer closes adb to the LAN with an iptables rule while tether is up, no longer keeps a block lease the watchdog enforced, and no longer reports a `lan` state. adb over the LAN is simply left reachable whenever adbd is serving. The `LanAdb` class, the `Failsafe` strategy slot, the setup screen's LAN toggle, and the watchdog's LAN-lease section are all gone. Bumps `SCRIPT_VERSION` to 12 so live boards retire the old watchdog.
* `New:` a real launcher icon — a blue circle with a white "T" — replaces the placeholder `@android:drawable/stat_sys_download_done` (a generic download arrow). Shipped as PNG mipmaps at every density with a round variant; the "Tether setup" entry in the app drawer now carries it.
* `Fix:` the health card reported the watchdog as NOT running after 1.2.40. `Watchdog.isRunning()` (the reader behind `watchdog_running` on the status/health report) still parsed the pidfile as a bare pid, but 1.2.40 changed it to `boot=<id>\npid=<pid>` — so `kill -0 $(cat pidfile)` was handed a two-line string and always failed, flipping the whole board to health "not ready" even though the watchdog was running fine. `isRunning()` now parses the pidfile the same boot-aware way `ensureRunning` does (read-only: it reports, it does not clear residue). Regression from 1.2.40's boot-scoped pidfile; the watchdog itself was never affected.
* `Fix:` the durable-identity serial guard now reaches boards that were already claimed. `mirrorControlIdentityIfNeeded` re-wrote the `/system` copy only when the token changed, so a board claimed by an earlier build kept a serial-less blob that the restore guard could only treat as "unverifiable, allow" — leaving the exact golden-image hole the 1.2.40 guard was meant to close open for the existing fleet. The mirror now also backfills a missing serial on reconnect (a board whose own serial cannot be read is left as-is, not churned).
A survival-path hardening pass (watchdog, boot recovery, durable identity), from a review of the one part of the agent that has to keep working when the app itself is dead. * `Fix:` the watchdog script is now swapped into place atomically. `Root.writeFile` wrote by truncating the destination and streaming into it, and `ensureRunning` rewrites `watchdog.sh` on every service start — so a live watchdog looping on that exact file could read half-written bytes and run garbled commands as root. Every root file is now staged beside its target and `mv`-d over it, so an open reader keeps the old inode whole and a failed write leaves the old file in place. * `Fix:` the watchdog's single-instance claim is boot-scoped and atomic. The pidfile carried a bare pid with no boot id; an unclean power loss (the normal failure on these boards) left it behind, and next boot a reused pid could match an unrelated process, so both the boot hook and `ensureRunning` concluded "already running" and no watchdog ever started — silently. The pidfile now records the boot id and the claim is taken with an atomic `mkdir`, so a prior-boot pidfile is never mistaken for a live watchdog and two boot-time spawns can no longer both proceed. * `Fix:` a board that reboots faster than the update prove window can no longer defer a rollback forever. The pending-update lease was re-armed in full on every new boot; it now carries a re-arm count and, past a cap, judges the build so an unproven one is reverted. * `Fix:` the durable `/system` identity is stamped with the board's serial and a restore refuses when it does not match — a golden or cloned `/system` image can no longer hand a new board another board's token. A board the backend has unclaimed now forgets its identity (prefs and the durable copy) instead of restoring the released token after a later wipe. * `Fix:` the reviver's `AlarmManager` leg no longer degrades silently on Android 12+. `SCHEDULE_EXACT_ALARM` is declared and `canScheduleExactAlarms()` is checked at runtime, falling back to a windowed wakeup (logged) rather than losing the leg. * `Fix:` an Android 12+ background foreground-service start that is refused (`ForegroundServiceStartNotAllowedException`, reachable via the reviver) is now recorded distinctly instead of vanishing into a generic log line. * `Upgrade:` the reviver's own documentation no longer claims to survive a force-stop — Android cancels a stopped package's alarms and jobs, so only the root watchdog covers that, and a non-root force-stopped board is a distinct gap. * Watchdog `SCRIPT_VERSION` 10 → 11, so live boards retire the old loop on update.
* `Fix:` the broker connection is always TLS. Whether to use TLS was inferred from the port number (`port != 1883`), so a hello response naming 1883 would have sent the per-device broker credential in clear. * `Fix:` the provisioning broadcast (`com.vms.tether.PROVISION`) is gated on a Tether-signed permission instead of `android.permission.INSTALL_PACKAGES`, which any privileged system app on a vendor ROM may hold and could have used to repoint the relay. `su -c am broadcast` at install time still works (root passes every permission check).
* `New:` a rollback is reported. The installer and the watchdog leave `update.rolled_back` when a revert succeeds; the reverted-to build reads it on start, logs `update.rolled_back`, and carries `last_rollback` in every status it answers. A self-heal used to be invisible — the only tell was the version drifting back on a later poll. * `New:` hello reports `version_code` and `agent_sha256` (the running APK's hash, computed once per install and cached), so control can tell which BYTES a board runs. "Behind"/"adopted" were decided from the version name alone, which a re-signed build shares with the bytes it replaces. * `Fix:` the watchdog no longer judges an update lease on its first pass after a reboot. A lease that predates the current boot is re-armed for a full prove window (300 s) from that boot instead; one missing pid ~80 s after boot was a slow first start as often as a dead build, and the backstop reverted good updates on that one look. Watchdog script version 10. * `Fix:` hpatchz runs with a 120 s bound and is killed if it outlives it. It runs on the agent's single worker thread beside the heartbeat, so a patch that never returned froze every command until the watchdog killed the agent — and the child survived that. * `Fix:` the installer checks `previous.apk` is non-empty before a rollback install; `Updater` quotes the running APK's path in its root shell like every other path; version codes are compared as `long` everywhere; `Root.writeFile` deletes its temp file on the failure path too.
* `Fix:` the self-update installer sets its proof-of-life mark AFTER `pm install` returns, and proof requires the new process to exist as well as beat. The mark used to be set before the install while the old process was still running and beating every 60 s, so a beat landing during the 10–30 s install "proved" a build that had never run, the pending lease was cleared, and a broken build was never rolled back. * `Fix:` a `tether.update` arriving while a previous update is still inside its prove window is refused (`skipped:update_pending`, override with `force`). Starting another would copy the unproven build over `previous.apk` — the only way back — and run two installers at once. * `Upgrade:` `app.update` verifies the payload the way the agent verifies its own: the archive must be the requested package and, if that package is installed, signed by the same key (`failed:package_mismatch` / `failed:signature_mismatch` / `failed:unsigned`). It used to install any APK whose sha matched the command.
* `Fix:` the tunnel status really comes from frpc's admin API now. 1.2.34 and
1.2.35 always fell back to the log: the app forbids cleartext HTTP everywhere,
which also forbade its own call to frpc on 127.0.0.1 ("Cleartext HTTP traffic
to 127.0.0.1 not permitted", surfaced by 1.2.35's `admin_error`). A network
security config now permits cleartext to loopback only; every off-device
connection stays TLS.
* `Upgrade:` the session wrapper keeps the previous session's frpc log as
`frpc.log.1` instead of truncating it, so a session whose frpc never came up
can still be explained after the next one starts.
* `Fix:` a tunnel status that fell back to the log says why (`admin_error`: the HTTP code or exception from frpc's admin API, or "no admin credentials"). On the bench board 1.2.34 reported `source: log` on a session whose frpc was demonstrably listening; the failure was swallowed and nothing could say where.
* `Upgrade:` the tunnel's state is asked of frpc itself, not read from its log. Each session's frpc now runs its admin API on a loopback port with a fresh per-session password (both in a root-only file beside the toml, wiped with it); `tether.status` and the tunnel verdict report frpc's own phase for our proxy (`running`, `start error`, `check failed`, …) and its own last error, with `source: api`. The log is read only when frpc does not answer (`source: log`), so a session opened by an older agent still reports. `Tunnel.close` asks frpc to stop through `POST /api/stop` before the kill, so frps sees the proxy leave at once — the window in which the next session's registration used to be refused as "already exists".
* `Upgrade:` the tunnel takes its whole grant at once. `remote.tunnel`'s secret, TTL, proxy name and relay address are read in one place (`Tunnel.Grant`) and `Tunnel.open` takes that grant — nothing has to be stored in the right order before calling it, which the old three-argument `open` silently required. * `Fix:` every string in the frpc config the board writes — relay address, token, proxy name, secret — is a TOML string. The relay address arrives over MQTT and went into the file raw. * `Upgrade:` the tunnel's status (`tether.status` and the verdict after a `remote.tunnel`) is built by `Tunnel` itself; a stale frpc log can no longer read as registered when frpc is not running. The ack after opening reports the TTL as honoured (clamped), not as requested.
* `Fix:` root shell hardening. The `package` of an `app.update` and the `set` component of `tether.launcher` arrive over MQTT and were spliced unquoted into `su -c` commands; a crafted value could have run as root. Both are now checked against their grammar and refused otherwise (`failed:bad_package`, `set_failed:bad_component`), and every root command built from external data goes through one shared quoting helper (`Root.q`). * `Fix:` a command handler that threw no longer kills the agent. Handlers run inside one guard that logs, records `command.failed` and acks `failed:internal:<cause>`; the heartbeat catches and always reschedules, so a single bad beat cannot stop proof-of-life for good. * `Fix:` the tunnel secret and proxy name are written into frpc's config as proper TOML strings — a quote or newline in either can no longer inject config. * `Fix:` the MQTT client/listener fields are `volatile`, as the other cross-thread fields already were.
* `Upgrade:` first release published with delta patches generated by control: boards on 1.2.30 receive a ~3 MB patch instead of the full APK. No code change in the agent beyond the version; this build exists to exercise the delta path end to end from a real publish.
* `New:` delta updates. When control offers patches with an update command
(`deltas:[{from_code,url,sha256,size}]`), the agent picks the one whose
`from_code` is the build actually installed, rebuilds the full APK from the
installed one with hpatchz (HDiffPatch, exec'd as root in its own process the
way frpc is), and only accepts a result that hashes to the full APK's sha256 —
the existing sha256 and signature checks then run unchanged. Any miss falls
back to the full download, so a delta can only shrink a download, never fail
one. 1.2.28→1.2.29 measured 2.7 MB instead of 21 MB. This build carries the
apply side; boards start receiving patches from the next release on.
* `New:` crash reporting. The agent registers an uncaught-exception handler (Sentry protocol, core JVM SDK) that reports a crash to Bugsink on vms-1, tagged with the board serial and versionCode — so a build that crash-loops in the field is visible instead of a board that just stops answering. Guarded: a failed init is swallowed, never thrown; deliberately not sentry-android (no NDK / manifest merge) so it builds and runs on API 25.
* `Fix:` the watchdog now disarms an old build's hardware watchdog **immediately** on start (before the first 60s check), and removes the `tetherwd` binary so a stale boot hook cannot relaunch it. A board upgraded from a "cockroach mode" build could reboot-loop at the hardware timeout before the disarm — which only ran inside the check loop — ever fired. It deliberately never writes to `/dev/watchdog` (which would re-arm a NOWAYOUT chip). * `New:` `tether.status` now carries a `health` readiness checklist — `provisioned`, `control_identity`, `root`, `watchdog_running`, `mqtt_connected`, `heartbeat_ok`, `cockroach_risk`, and a single `ready` — so a half-configured board, or one still at reboot-loop risk, is visible at a glance instead of looking normal.
* `Fix:` after an app update (and after a rollback), the agent now reports its package inventory (`tether.apps`) instead of only acking. Control caches the installed version from that report, so the console reflected the *old* version until some unrelated inventory request — the machine's screen showed the new build while the admin still showed the old one. Reporting on both terminal paths that change what is installed makes the console update in real time.
No notes for this build.
No notes for this build.
No notes for this build.
No notes for this build.
* Fix: CI published every build with an empty release note. Nothing in `release:publish` ever called the notes endpoint, so the only annotated build in the channel was one somebody had set by hand — and an operator reading `tether releases` to decide what to roll out had nothing to read, which is the point of that list. The note is now derived from this file's section for the version being published, so the prose is written once, reviewed once, and cannot drift from the changelog. A missing section warns rather than fails: by that point the build is already published, and failing there would leave a release half done over a note.
Surfaces update state to the operator: tether.status now carries update_failed when a rollback has itself failed, and update_pending while a build is inside its prove window. Both markers were written on disk and read by nothing, so a board that gave up looked entirely normal.
No functional change. A version bump so the repaired self-update path could be exercised end to end on a real board.
Fixes the backup check that refused every update in 1.2.17/1.2.18. Root.DIR is 0700 root-owned and the agent is an ordinary app uid, so java.io reported a healthy backup as missing. Both sizes now come from stat through the root shell, comparing source to copy.
Bumps SCRIPT_VERSION so 1.2.17's watchdog change actually reaches a running board -- the live watchdog is a detached shell that never re-reads its script. Adds a digest gate so editing the script without bumping the version fails the build. DO NOT INSTALL. Carries 1.2.17's broken backup check; refuses every subsequent update. Fixed in 1.2.19.
Update deadline survives a reboot: the prove window is now also a boot-id + uptime lease enforced by the watchdog, so a reboot mid-update can no longer strand a board on a build that never checked in. Backup verified before install; a failed rollback is recorded rather than exiting silently. DO NOT INSTALL. The backup check in this build used java.io against a root-owned 0700 directory and refuses every subsequent update with failed:backup_missing. Fixed in 1.2.19. A board on this build must be recovered by hand over a tunnel.
Keeps tether visible in the app drawer after setup
Fix: opening a tunnel no longer restarts adbd when it's already serving
Emits reboot lifecycle events for live progress; adds a LAN adb toggle to the Setup screen
Reports which installed apps are Tether-integrated.
Richer board telemetry: ROM build, kernel, screen density, and live free storage and RAM.
Tether identity is now the control token plus the frps token only — the legacy api_key/v1 path is dropped.
Persists the control token durably, so a factory wipe never strands a board; backfills the token for boards claimed before this build.
Applies the frps tunnel address the control plane sends (falling back to the resolved IP), fixes auth-failure visibility, and refactors the monolithic agent service into smaller, tested pieces (Heartbeat, decision table).
Fix: pulls the board-reboot, framework-restart and hardware-watchdog rungs back out of the watchdog escalation — the aggressive recovery ladder was doing more harm than the wedges it chased.
Fixes TLS on Android 6 / API 23 after Let's Encrypt's 2026 chain change. The *.mtm.mn cert now chains leaf -> YR1 -> ISRG Root YR -> ISRG Root X1; the agent bundled only Root X1 and API 23 cannot path-build through the new cross-signed Root YR, so every hello and MQTT connect failed 'Trust anchor not found' while API 25+ boards kept working. 1.2.6 bundles ISRG Root YR as a direct anchor. Verified on the tether23 (API 23) emulator against the live chain: control.claimed + mqtt.connected, no trust error. Also carries 1.2.5's cockroach hw-daemon SELinux fix (run the daemon from a shell-context copy; still OFF by default).
Fix: the cockroach hardware-watchdog daemon runs from a shell-context copy of itself, so restarting the agent no longer takes it down with it.
Fixes 1.2.3s fatal MQTT regression (SNI factory now implements no-arg createSocket). Ships the full feature set working: DNS-outage fallback — connects to the cached IP through a site DNS failure while verifying the hostname (the .1108 cure); frps default moved to the hostname tunnel.mtm.mn; setup screen forces a real reconnect + live status/event log; triple-tap top-right closes the setup screen; cockroach mode present but OFF by default (its hardware-watchdog daemon still has a reboot-on-arm bug under investigation — do not arm yet).
DO NOT USE — superseded by 1.2.4. This build has a critical MQTT regression: the SNI socket factory did not implement the no-arg createSocket() Paho needs, so every MQTT connect failed ("Unconnected sockets not implemented") and the agent could not reach the broker. Caught on the bench canary before any fleet rollout. Its intended features (DNS-outage cached-IP fallback, cockroach mode, setup-screen force-reconnect + live status, triple-tap-close) all ship working in 1.2.4.
Resilience Slice 2: watchdog escalation ladder. When repeated process-restarts do not revive tether, the root watchdog restarts the Android framework (zygote); as a default-off, day-capped last resort it can reboot the board. Normal mode still only restarts the app.
Resilience Slice 1: durable /data persistence + /system-wipe detection. The agent self-repairs its boot hooks on every run, tracks a generation counter mirrored to a /system marker, and reports persistence_generation / hooks_present / hook_wiped on hello — so a board that re-flashes /system (keeping only user data) is detected, healed, and audited (WIPED in tether devices).
Any-Android compatibility, plus new remote operations.
Install reach
- Runs on Android 5.0+ (API 21). Every newer-API call is version-guarded,
so old boards simply skip those branches instead of failing to install.
Fixes the UniWin M190 coffee machine (Android 6 / API 23), which refused
a minSdk-24 APK outright ("no icon, nothing on tap").
- MQTT client swapped to Eclipse Paho — no API-24 surface — with core
library desugaring, so the control channel connects on old Android.
- TLS now trusts the bundled ISRG Root X1 alongside the system CAs, so a
valid *.mtm.mn certificate verifies on pre-7.1.1 devices. Hostname
verification kept.
- frpc shipped for armeabi-v7a, arm64-v8a and x86_64: every board the agent
installs on can also open a tunnel.
New capabilities
- Device-capability tiers (ROOT / MANAGED / BEST_EFFORT) reported in the
hello; root-only commands are refused on lower tiers.
- Hardware probe (model, chip, RAM, storage) reported and shown.
- Installed-apps list, launcher set, and remote log-fetch.
Verified on real hardware: bench .69, plus .1108 and .222, all updated and
reconnected on this build.
LAN status freshness: the board reports its firewall state on every heartbeat, not only when it changes — so the LAN column never goes stale after a missed update, and an operator can trust what it says before driving out.
adb-over-LAN fix: "open" now means the LAN can genuinely reach adb, not merely that adbd is running. The reported state matches what a technician actually experiences at the machine.
adb-over-LAN fix: opening the LAN actually makes adbd answer, not merely removes the firewall rule — so "open" means a technician can connect, not just that the port is unblocked.
adb-over-LAN precedence: an operator asking to close the LAN now beats their own still-open timed window, so a board is never left exposed by a race. The board also reports its adb/LAN status on every hello, keeping the fleet view current.
adb-over-LAN, end to end (the LanAdb build): a technician can open adb on the local network on demand and read its state, driven from the CLI, TUI and control server. adb stays closed to the LAN while tether is the live channel, so it is exposed only when explicitly asked for.
adb-over-LAN, end to end (the LanAdb build): a technician can open adb on the local network on demand and read its state, driven from the CLI, TUI and control server. adb stays closed to the LAN while tether is the live channel, so it is exposed only when explicitly asked for.
Zero-touch bring-up: a board comes up without an API key provisioned first, and a one-time setup icon appears on a fresh machine — so a new board can be enrolled without plugging in adb.
adb status accuracy: the board reports the state that actually resulted from a gate change, not the branch the code took to get there — so the LAN column reflects what a technician would really find.
adb status accuracy: the board reports the state that actually resulted from a gate change, not the branch the code took to get there — so the LAN column reflects what a technician would really find.
Tunnel reliability: one frpc supervisor per session, so a repeat tunnel request no longer spawns a competing supervisor that breaks the tunnel it was asking for. Command replies are sent only on topics the agent is permitted to publish.