tether — releases

Every agent build the fleet can update to, newest first. Download · Docs

1.2.85 · versionCode 108

2026-09-17 09:30 UTC · 20.4 MB · 1029df9739c3be3b

* `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.

1.2.84 · versionCode 107

2026-09-17 08:45 UTC · 20.4 MB · e2d9224dc06af1b7

* `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.

1.2.83 · versionCode 106

2026-09-17 08:26 UTC · 20.4 MB · ecc9c9fe27b5eb94

* `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.

1.2.82 · versionCode 105

2026-09-17 08:07 UTC · 20.4 MB · f41dc4483fba922a

* `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.

1.2.81 · versionCode 104

2026-09-17 02:21 UTC · 20.4 MB · bf1b256a003ac05e

* `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.

1.2.80 · versionCode 103

2026-09-16 17:45 UTC · 20.4 MB · 17765d75d78aa045

* `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.

1.2.78 · versionCode 101

2026-09-16 10:09 UTC · 20.4 MB · e0ae23acf38e63bb

* `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.

1.2.77 · versionCode 100

2026-09-16 08:45 UTC · 20.4 MB · abaa20279298f2c8

* `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.

1.2.76 · versionCode 99

2026-09-16 08:30 UTC · 20.4 MB · 0add9d8d27f3e929

* `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.

1.2.75 · versionCode 98

2026-09-15 10:52 UTC · 20.4 MB · 198d403a0d99fc45

* `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.

1.2.74 · versionCode 97

2026-09-15 04:05 UTC · 20.4 MB · 31f54d8550831435

* `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.

1.2.73 · versionCode 96

2026-09-14 17:46 UTC · 20.4 MB · 17e66e23e85f5848

* `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.

1.2.72 · versionCode 95

2026-09-14 17:18 UTC · 20.4 MB · 5d5aec8abe4bb8f1

* `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.

1.2.71 · versionCode 94

2026-09-14 17:11 UTC · 20.4 MB · 5215bb4b4b28409e

* `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.

1.2.70 · versionCode 93

2026-09-14 16:53 UTC · 20.4 MB · 2bec485987b2ca33

* `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.

1.2.69 · versionCode 92

2026-09-14 16:26 UTC · 20.4 MB · b1f7f4887752d597

* `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.

1.2.68 · versionCode 91

2026-09-14 08:56 UTC · 20.4 MB · bbcafb0dfdf36fbf

* `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.

1.2.67 · versionCode 90

2026-09-14 08:17 UTC · 20.4 MB · 2536031e34e9883e

* `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.

1.2.65 · versionCode 88

2026-09-12 09:56 UTC · 20.4 MB · 54765f137f842b97

* `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.

1.2.64 · versionCode 87

2026-09-12 09:47 UTC · 20.4 MB · 7dc4ef477528df9f

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.

1.2.63 · versionCode 86

2026-09-11 13:34 UTC · 20.4 MB · abae203b3fa89064

* `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`.

1.2.62 · versionCode 85

2026-09-11 13:19 UTC · 20.4 MB · 04c550a2037c398c

* `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.

1.2.61 · versionCode 84

2026-09-11 12:22 UTC · 20.4 MB · 304d00fa86840c69

* `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.

1.2.60 · versionCode 83

2026-09-11 04:29 UTC · 20.4 MB · 2c03cd0379ee8e83

* `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).

1.2.59 · versionCode 82

2026-09-11 04:01 UTC · 20.4 MB · 3f94687cd1275bdb

* `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.

1.2.58 · versionCode 81

2026-09-11 03:53 UTC · 20.4 MB · adc830abe9d974f7

* `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.

1.2.57 · versionCode 80

2026-09-11 03:37 UTC · 20.4 MB · 0a88c1835bfec1cb

* `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.)

1.2.56 · versionCode 79

2026-09-11 02:48 UTC · 20.4 MB · ea1308372c6ff86e

* `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.

1.2.55 · versionCode 78

2026-09-11 02:07 UTC · 20.3 MB · ac3b372eec72f9e3

* `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.

1.2.54 · versionCode 77

2026-09-11 01:51 UTC · 20.2 MB · 863a7f9f6ba8ea23

* `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.

1.2.53 · versionCode 76

2026-09-11 01:35 UTC · 20.2 MB · e43e5eb3cb098b7c

* `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`.

1.2.52 · versionCode 75

2026-09-10 10:56 UTC · 20.2 MB · 109a4f823f5add9c

* `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.

1.2.51 · versionCode 74

2026-09-10 10:50 UTC · 20.2 MB · 1f0bed523557e90a

* `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.

1.2.50 · versionCode 73

2026-09-10 10:13 UTC · 20.2 MB · 9e35ad4e238c2dea

* `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.

1.2.49 · versionCode 72

2026-09-10 08:26 UTC · 20.2 MB · 6750b3d9bb41e2b4

* `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.

1.2.48 · versionCode 71

2026-09-10 06:35 UTC · 20.2 MB · 413d9586058b7709

* `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.

1.2.47 · versionCode 70

2026-09-10 04:13 UTC · 20.2 MB · af4c5c08f4f1609a

* `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.

1.2.46 · versionCode 69

2026-09-10 03:08 UTC · 20.2 MB · de6bc6cce18d04b6

* `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.

1.2.45 · versionCode 68

2026-09-08 08:13 UTC · 20.2 MB · 2ccfbeb5fe849b96

* `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.

1.2.44 · versionCode 67

2026-09-08 08:06 UTC · 20.2 MB · 3fafcf57a3b4d998

* `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.

1.2.43 · versionCode 66

2026-09-08 07:38 UTC · 20.2 MB · 61c18beab89ab7ee

* `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.

1.2.42 · versionCode 65

2026-09-08 05:09 UTC · 20.1 MB · 604f42805e08c78a

* `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.

1.2.41 · versionCode 64

2026-09-08 04:51 UTC · 20.1 MB · 37dcc82bdf602c45

* `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).

1.2.40 · versionCode 63

2026-09-08 04:34 UTC · 20.1 MB · 4c8b55687662afcf

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.

1.2.39 · versionCode 62

2026-09-07 07:39 UTC · 20.1 MB · ad61e173eba084ab

* `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).

1.2.38 · versionCode 61

2026-09-07 02:56 UTC · 20.1 MB · df20e23dd22b805d

* `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.

1.2.37 · versionCode 60

2026-09-07 02:37 UTC · 20.1 MB · 22f1726afc9818fa

* `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.

1.2.36 · versionCode 59

2026-09-06 10:21 UTC · 20.1 MB · 479e222c588a1606

* `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.

1.2.35 · versionCode 58

2026-09-06 10:09 UTC · 20.1 MB · 8f6d6514df4955d0

* `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.

1.2.34 · versionCode 57

2026-09-06 09:58 UTC · 20.1 MB · 1df5b8235d5bd5d2

* `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".

1.2.33 · versionCode 56

2026-09-06 07:42 UTC · 20.1 MB · dcc5894166c73dd4

* `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.

1.2.32 · versionCode 55

2026-09-06 06:57 UTC · 20.1 MB · 781bb5c290ed1914

* `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.

1.2.31 · versionCode 54

2026-09-06 04:36 UTC · 20.1 MB · 0fed66e59408dac7

* `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.

1.2.30 · versionCode 53

2026-09-05 16:42 UTC · 20.1 MB · d6f8a75ca54376a5

* `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.

1.2.29 · versionCode 52

2026-09-04 17:01 UTC · 20.1 MB · 69494e6f150c3fb6

* `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.

1.2.28 · versionCode 51

2026-09-04 16:42 UTC · 19.8 MB · 2c91cab8b5958ce9

* `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.

1.2.27 · versionCode 50

2026-09-04 15:27 UTC · 19.8 MB · e56730d2032d6d0a

* `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.

1.2.26 · versionCode 49

2026-09-04 12:40 UTC · 19.8 MB · 62df3c4f18343a06

No notes for this build.

1.2.25 · versionCode 48

2026-09-04 12:31 UTC · 19.8 MB · 7a57ebcf9df7ce60

No notes for this build.

1.2.24 · versionCode 47

2026-09-04 12:17 UTC · 19.8 MB · 9fadccd805770e9b

No notes for this build.

1.2.23 · versionCode 46

2026-09-04 11:00 UTC · 19.8 MB · 0e5cc82ad39ccef9

No notes for this build.

1.2.22 · versionCode 45

2026-08-12 12:51 UTC · 19.8 MB · 89bedd88c4c36b65

* 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.

1.2.21 · versionCode 44

2026-08-12 12:44 UTC · 19.8 MB · f95b745ade535a23

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.

1.2.20 · versionCode 43

2026-08-12 12:16 UTC · 19.8 MB · 1e173af87e0af7d3

No functional change. A version bump so the repaired self-update path could be exercised end to end on a real board.

1.2.19 · versionCode 42

2026-08-12 12:06 UTC · 19.8 MB · f15255ac519969f0

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.

1.2.18 · versionCode 41

2026-08-12 11:54 UTC · 19.8 MB · 80ad7b88d8044510

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.

1.2.17 · versionCode 40

2026-08-12 11:47 UTC · 19.8 MB · 13a5669e0b1e88ce

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.

1.2.16 · versionCode 39

2026-08-09 08:45 UTC · 19.8 MB · 8ace6624d85337f8

Keeps tether visible in the app drawer after setup

1.2.15 · versionCode 38

2026-08-09 08:28 UTC · 19.8 MB · 9457a2981cc60484

Fix: opening a tunnel no longer restarts adbd when it's already serving

1.2.14 · versionCode 37

2026-08-09 04:00 UTC · 19.8 MB · fd78041d4afe8ab5

Emits reboot lifecycle events for live progress; adds a LAN adb toggle to the Setup screen

1.2.13 · versionCode 36

2026-08-04 05:44 UTC · 19.8 MB · 86cf32183642b589

Reports which installed apps are Tether-integrated.

1.2.12 · versionCode 35

2026-08-04 05:18 UTC · 19.8 MB · b33af2625290fe7a

Richer board telemetry: ROM build, kernel, screen density, and live free storage and RAM.

1.2.10 · versionCode 33

2026-08-03 13:35 UTC · 19.8 MB · 11e166fc2342aaa4

Tether identity is now the control token plus the frps token only — the legacy api_key/v1 path is dropped.

1.2.9 · versionCode 32

2026-08-03 10:18 UTC · 19.8 MB · aaeb3fffce42fd87

Persists the control token durably, so a factory wipe never strands a board; backfills the token for boards claimed before this build.

1.2.8 · versionCode 31

2026-08-03 08:57 UTC · 19.8 MB · effd0bcce4561c1c

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).

1.2.7 · versionCode 30

2026-08-03 03:46 UTC · 19.8 MB · 381a6dd056109483

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.

1.2.6 · versionCode 29

2026-08-03 03:00 UTC · 19.8 MB · 37d6c73e779d6282

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).

1.2.5 · versionCode 28

2026-08-03 02:32 UTC · 19.8 MB · 906de8f78b51f434

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.

1.2.4 · versionCode 27

2026-08-03 02:02 UTC · 19.8 MB · f579089462b81a51

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).

1.2.3 · versionCode 26

2026-08-03 01:46 UTC · 19.8 MB · 9ff3aee03e7af8a2

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.

1.2.2 · versionCode 25

2026-08-03 00:18 UTC · 19.8 MB · d6ebb5e8a8be0d61

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.

1.2.1 · versionCode 24

2026-08-02 16:35 UTC · 19.8 MB · ab03d4c32a33c9b6

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).

1.2.0 · versionCode 23

2026-08-02 13:39 UTC · 19.8 MB · 2ab7beb85e48d1ca

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.

1.1.15 · versionCode 22

2026-08-01 17:40 UTC · 9.6 MB · 5c44dce45f6fca24

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.

1.1.14 · versionCode 21

2026-08-01 17:19 UTC · 9.6 MB · 63f6c440fdc30491

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.

1.1.13 · versionCode 20

2026-08-01 16:06 UTC · 9.6 MB · 96ce93e407623601

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.

1.1.12 · versionCode 19

2026-08-01 13:21 UTC · 9.6 MB · 05dd2412cc77ebde

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.

1.1.11 · versionCode 18

2026-08-01 11:52 UTC · 9.6 MB · fb2ac502ca7484d4

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.

1.1.11 · versionCode 18

2026-08-01 11:34 UTC · 9.6 MB · 15dd77a64291120f

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.

1.1.10 · versionCode 17

2026-07-31 09:46 UTC · 9.6 MB · 59b472bc43e19891

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.

1.1.8 · versionCode 16

2026-07-31 05:42 UTC · 9.6 MB · 8ef1d381b071bfae

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.

1.1.8 · versionCode 16

2026-07-31 05:08 UTC · 9.6 MB · d8ef380069d6a9cf

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.

1.1.7 · versionCode 15

2026-07-31 05:06 UTC · 9.6 MB · 7122ca86c8ba8629

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.