---
title: Mobile Access — staying connected (part 2)
---

# Mobile Access — staying connected (part 2)

## What it is

This is part 2 of the [Mobile Access](mobile-remote-access.md) page. It covers the connection itself: what you see when it wobbles, and what the app does about it.

## Where to find it

On your phone, in the running app — the indicators appear on screen, and the settings that govern them live in the desktop's mobile-access configuration.

## How it behaves

### Connectivity indicators (the connection banner and the dropped-action toast)

When you're using Omniscio in a browser (mobile or desktop), connectivity is fragile in a way the desktop app never has to deal with — Wi-Fi drops, cell handoff, the OS suspending a backgrounded tab, the WebSocket idling out behind a corporate proxy. Omniscio shows a slim **connection banner** across the top of the app so you can tell at a glance whether what you're looking at is live, slow, or stale. It can hold four strips:

- **Offline.** An amber strip reading **"You are offline. Some features are unavailable."** It only appears after the browser has reported `offline` for **5 seconds** continuously — a brief blink (sub-second `online → offline → online`, common on cell handoff) is suppressed so it doesn't flash every time you walk between rooms. It disappears the moment the browser reports `online` again; the 5-second gate applies only going _into_ offline. It shows in the desktop app too.
- **Reconnecting…** A calm grey strip with a pulsing dot while the WebSocket is down and the app is getting it back. It follows **time since the outage began**, not the number of reconnect attempts: it appears once the outage is **2 seconds** old (`WS_RECONNECT_CHIP_GRACE_MS`), however long each attempt hangs. One attempt on a black-holed link can run the full 12-second connect timeout, which is how the old attempt-count gate left a phone blank for 25–44 seconds (2026-09-24 report). Every connection attempt starts the clock — a cold page load and each scheduled retry in [/src/renderer/src/lib/ipc-ws-transport.ts](/src/renderer/src/lib/ipc-ws-transport.ts) calls `markReconnecting()` — and a later attempt in the same outage never restarts it, so a blip that recovers inside 2 seconds never flashes the strip. **Coming back to the app is different:** that reconnect is deliberate, so it owns the strip — it drops any strip (and any running clock) left over from while the page was hidden, and shows it only if the reconnect has not opened after **1.5 seconds** (`RESUME_RECONNECT_CHIP_DELAY_MS`). Most resumes reconnect in about half a second, so usually you see nothing at all. Your own tap shows it at once instead of waiting out the grace: one that fails because the link is already down, one made while a reconnect attempt is under way, and one of yours that the phone drops when it gives up on the link (that one also says it didn't go through — see below). If an action you take fails because the link is already down, the strip shows **at once** (`markReconnectingNow()`), before the "that didn't go through" toast. A successful reconnect clears it; a rejected device credential (a `4001` close) cancels it, so it never appears over the pairing card. How fast it clears is covered in **Reconnect speed (page resume + backoff)** below.
- **Waiting for your computer…** A calm grey strip with an hourglass for the other kind of trouble: the WebSocket still looks open, but nothing has come back from your computer while a request is waiting on it — either the computer is busy, or the link has quietly died and the phone hasn't given up on it yet. It appears once **10 seconds** have passed since the first unanswered heartbeat ping with nothing received in that time (`noteSilentWait()` in the transport, anchored to that first ping so a slow-but-alive reopen never shows it), and **any** message from the computer clears it. It is display-only: it never changes when the phone declares the link dead — the heartbeat's deferral rules and their 20-second cap still decide that — and once the phone gives up on the link, "Reconnecting…" replaces it at once. It also appears sooner for a request **you** made — one that began within 5 seconds of a tap or key press — once that request has had no answer for **5 seconds** *and* nothing at all has come back from the computer for **10 seconds** (`HOST_QUIET_MS`, the transport's own "this socket is alive" window), including when the screen you tapped has to download its code first ([/src/renderer/src/lib/ws-user-wait.ts](/src/renderer/src/lib/ws-user-wait.ts)). **Both** have to hold: any frame from the computer — a pong, a push, any reply — clears the strip and holds it back while it keeps arriving, because the strip's words are a claim about the computer, and a computer that is answering is not one you are waiting on. That second condition is what keeps the strip honest on a busy box: a phone's request tail is fat (measured 2026-09-26 over ~2 h of `main.log`: 4,889 session-history fetches, p50 240 ms, p99 26.7 s, 3.1 % past 5 s), one screen open fires dozens of requests at once, and on elapsed time alone the strip lit up continually on links whose own tape shows a pong landing a second later — the "continually getting waiting on your computer notices on mobile" report. A genuinely dropped link (the 2026-09-24 report) delivers nothing, so the strip still appears there, now about 10 seconds after the drop instead of 5. The app's own background traffic (refreshes, heartbeat timers, log shipping, code warms, the conversations it quietly pre-loads for the sessions you are likely to open next, and the reply suggestions it prepares in advance) never counts, and it clears the moment that answer arrives. Those pre-loads start right after your taps and wait on the computer's low-priority queue, so counting them once kept the strip up for half a minute after everything you asked for had already arrived — it looked exactly like a dropped connection (fixed 2026-09-25). Off switch for this tap-triggered part: `localStorage['amc-disable-waiting-notice'] = '1'` in the phone's browser.
- **Connection lost — tap to reload.** A red strip once the fast-retry budget is used up (`markGaveUp()`, RT-F005). The transport keeps retrying quietly underneath at a capped backoff, but you get a distinct, actionable sign that live updates have stalled instead of a silently frozen screen; tapping it reloads the page, the guaranteed recovery. A successful reconnect clears it, and so does reopening the app, which starts a fresh cycle and shows "Reconnecting…" instead.

At most one of the three link strips shows at a time — **Connection lost** first, then **Reconnecting…**, then **Waiting for your computer…** — while the offline strip is independent and can sit above one. The link strips are browser-only: the desktop app has no WebSocket to lose, so [/src/renderer/src/app/ConnectionBanner.tsx](/src/renderer/src/app/ConnectionBanner.tsx) never draws them there.

**Always visible, never in the way.** The banner is drawn on the page itself, on the `connectionBanner` layer (1150) of the [z-index ladder](/src/renderer/src/lib/z-index.ts), above every dialog, sheet and menu, so a dropped link is never hidden behind whatever you have open. It cannot live inside the app's own tree: the app root is its own stacking layer, and dialogs are drawn on the page beside it, so nothing inside the app can paint over them. An invisible placeholder at the top of the app keeps the banner's space, so it covers nothing. The phone's notifications are drawn on the page too, one layer higher (`toast`, 1200), so a notification that lands on the banner's strip while you type stays readable. Taps pass through the banner to whatever is underneath; only the reload button takes one.

**Dropped-action heads-up.** If a data-changing action you take (send a reply, snooze, save) can't reach the server because the WebSocket is momentarily down, a one-off amber toast — **"You're offline — that didn't go through. It'll work again once you reconnect."** — tells you _that specific action_ didn't land, so a fire-and-forget mutation made offline never vanishes silently (RT-F002). It is deliberately scoped to **actions you take**: the app's own background traffic (session / inbox / status polls, plus the activity + UI-usage + diagnostic heartbeat timers) hits the same dead socket but simply retries on reconnect, so it never raises the toast. The suppress-vs-surface split is decided by `isSilentOfflineDropChannel` in the not-connected path of [/src/renderer/src/lib/ipc-ws-transport.ts](/src/renderer/src/lib/ipc-ws-transport.ts) — a background invoke is suppressed, a genuine user action surfaces — and it coalesces to one toast per outage (`markReconnectingNow()` and the diagnostic breadcrumb still fire for a suppressed drop, so the Reconnecting… strip above is unaffected). "Background invoke" is BOTH the app's idempotent reads (caught by `isDedupableReadChannel`) AND its automatic write timers (`engagement:heartbeat` / `ui-usage:record` / `renderer:diag-report`, plus the log shipping `renderer:log` / `renderer:log-batch`, which lead with a write-ish verb so the read classifier can't catch them). The same message also fires when one of your requests was already sent and the phone drops it on giving up the link, not only when a tap finds the link down. Before the read gate it fired for _every_ dropped call including background reads; the write timers slipped through even after it, so on mobile it "kept popping up" with nothing the user did behind it until both classes were suppressed.

**Where the state lives.** The offline strip and the link strips are intentionally independent: you can be online (the browser thinks the network is fine) while the WebSocket is mid-reconnect, and you can be flat-out offline. Together they tell "the network is gone" apart from "the pipe to your computer is being rebuilt" and "the pipe is open but your computer is slow to answer". All of it is read from the `useConnectivityStore` Zustand store ([/src/renderer/src/stores/connectivity-store.ts](/src/renderer/src/stores/connectivity-store.ts)) — `isOnline` for the offline strip, `wsReconnecting` / `wsAwaitingReply` / `wsWaitingOnHost` (the tap-triggered wait, set only by `ws-user-wait.ts`) / `wsGaveUp` for the link strips — and set through intent-named helpers in [/src/renderer/src/lib/ws-reconnect-state.ts](/src/renderer/src/lib/ws-reconnect-state.ts) (`markReconnecting` / `markReconnectingNow` / `markAwaitingReply` / `markConnected` / `markGaveUp` / `markAuthStopped`), which also own the one grace timer, plus a `window.addEventListener('offline')` debounce timer for the offline strip. The promises are locked in [/.claude/memory/contracts/mobile-ws-stall-guard-contract.md](/.claude/memory/contracts/mobile-ws-stall-guard-contract.md) (Connection banner).

### Reconnect speed (page resume + backoff)

When you tab back to Omniscio on mobile after the OS has backgrounded the browser — even briefly, like a quick file-picker takeover or a phone call — the WebSocket has often gone silently dead. The TCP connection still looks `OPEN` from the browser's view, but the radio went to sleep, the cell↔Wi-Fi handoff dropped packets, or a captive portal is now blocking it. Omniscio's reconnect path is tuned for this exact case: **as soon as the page becomes visible again, the dead socket is aborted and a fresh WebSocket connects in the same tick**. The user-visible result is that the reconnect finishes in well under a second instead of occasionally hanging for ~20s while the browser waits out a stale TCP timeout — and, because a resume reconnect only shows the "Reconnecting…" chip past 1.5 s, a normal return to the app shows no chip at all.

**Requests made during a resume wait for the new connection.** On a phone the first reconnect attempt often dies at once as the network resets on wake, and the next one opens about half a second later. Anything the app (or you) asks for in that gap — a list refresh, a rename, a reply — now waits for that open and then goes through, rather than failing with "WebSocket not connected" and a "that didn't go through" toast. The wait is bounded by `WS_READ_HOLD_FOR_OPEN_MS` (15 s) from the wake; past that the connection is genuinely down, and the request fails the usual way, with the chip and the heads-up. A connection that drops on its own (no resume) still fails requests straight away, as before. Invariants: `chip-deliberate-reconnect-delay` and `deliberate-reconnect-holds-requests` in [/.claude/memory/contracts/mobile-ws-stall-guard-contract.md](/.claude/memory/contracts/mobile-ws-stall-guard-contract.md).

**Two backoff tables.** Reconnect attempts pick a delay schedule based on whether the page is currently visible:

- **Visible** (you're staring at the screen): `0ms, 500ms, 1s, 2s, 3s, 5s, 10s, 20s, 30s, 30s, …`. The first attempt fires synchronously inside `scheduleReconnect()` — no `setTimeout` tick, the new WebSocket is constructed in the same JS task as the close handler that scheduled it.
- **Hidden** (tab backgrounded, screen off): `1s, 2s, 4s, 8s, 16s, 30s, …`. Slower because there's no user waiting and we want to spare the radio.

The schedule is selected per-attempt off `!document.hidden`, so a backgrounded retry that lands while you're tabbed back to Omniscio picks the visible table.

**`forceImmediateReconnect()` semantics.** Three resume paths share the same primitive in [/src/renderer/src/lib/ipc.ts](/src/renderer/src/lib/ipc.ts):

1. **`triggerResumeRefresh` after a hidden→visible flip ≥ 2s.** Sends a best-effort `close(4001, 'resume-refresh')` for server-side telemetry — the actual reconnect does **not** wait for the close handler to fire (iOS Safari can take 5–10s to deliver `close` on a dead TCP connection). After the best-effort close, the orphan socket reference is aborted and a new one is constructed synchronously.
2. **Quick-flip visibility resume (< 2s hidden) when the socket is not OPEN.** If the socket is `CONNECTING` (a zombie handshake that started before the OS suspended the page and never completed), `CLOSING`, `CLOSED`, or `null`, the resume goes through `forceImmediateReconnect`. Calling plain `connectWs()` here would short-circuit on `readyState === CONNECTING` and leave us stuck with the dead zombie until its OS-level handshake timeout (10–30s on iOS Safari) — that's the bug that produced the occasional ~20s tail.
3. **`pageshow` from bfcache.** Same path as visibility resume — bfcache restores can leave a dead socket in any `readyState`.

In every path the helper clears any pending reconnect timer, resets the attempt counter, starts the deliberate-reconnect window (the 1.5 s chip delay and the request hold above), closes the orphan socket (best-effort `try/catch`), rejects in-flight `invoke()` promises so callers retry against the new socket, stops the heartbeat, and calls `connectWs()` synchronously.

**Orphan guards on the WebSocket listeners.** After `connectWs()` constructs a socket, every listener (`open` / `message` / `close` / `error`) closure-captures it as `newSocket` and short-circuits with `if (newSocket !== ws) return` at the top of its body. This is the safety net for the synchronous-replace pattern: when `forceImmediateReconnect` aborts an in-flight handshake and swaps in a new `ws`, a late-firing event from the orphan can no longer double-schedule reconnect work, double-fire `markConnected`, or double-drain the command queue.

### Self-recovery under load (server-side watchdog)

The web-access server runs **inside the main Electron process**, so under an extreme local load storm (the machine pinned at ~90%+ CPU by parallel builds + many live sessions) it can stop completing new mobile connections, emit **no error**, and recover only on a full app restart — a silent outage to the person on the phone (incident 2026-06-17: ~69 min of zero connections, "mobile isn't accessible. wth?"). A main-process **watchdog** ([/src/main/services/web/web-access-watchdog.ts](/src/main/services/web/web-access-watchdog.ts)) closes that gap. The exact internal wedge couldn't be pinned to one line (rate-limiter / origin-guard / keepalive theories were each falsified against the live logs), so the fix is deliberately **mechanism-agnostic resilience + visibility**, not a one-line bug fix:

- **Active self-probe.** Every 60 s (first probe after a 90 s boot grace) it dials the server's OWN loopback `http://127.0.0.1:<port>/health/live` (pre-auth, 200-when-alive) through the shared `fetchWithTimeout`. Literal `127.0.0.1`, never `localhost` (the server may bind IPv4-only and `localhost` can resolve to `::1` and miss it). It probes only when `webAccessEnabled` + a token are set — never on "zero clients connected", which is ambiguous (nobody may currently be using mobile).
- **Restart just the listener.** After **3 consecutive** probe failures it runs `stopWebAccess()` then `startWebAccess(...)` — never an app restart — with a **5-minute cooldown** so it can't flap, and a re-check of the shutdown flag immediately before the restart so it never re-binds a server the app is tearing down.
- **Tells you.** One dismissible inbox alert per episode (deduped by a stable key via the shared `createAlert` primitive, so a flap never spams), with a different message if the restart didn't help. Every probe failure + restart is logged under `[WebAccess][watchdog]` — the 2026-06-17 incident left no trail; this guarantees the next one does.
- **Default-on, with an `AMC_DISABLE_WEBACCESS_WATCHDOG=1` kill switch**; it appears in the Resources diagnostics panel. **Honest limit:** it shares the same main event loop, so it can't cure an _absolute_ CPU peak in real time — it recovers the **persistent** wedge (server stayed dead after load eased) and makes it visible + logged. Invariants are locked in [/.claude/memory/contracts/web-access-watchdog-contract.md](/.claude/memory/contracts/web-access-watchdog-contract.md).

### Freeze-aware transport under load (don't mistake a stall for a dead connection)

Under that same storm the main event loop can freeze for many seconds at a time (11–64 s observed, blocked on saturated disk I/O). Because the keepalive ping, the watchdog probe, and the reconnect chip all run on that loop, a freeze used to look like a dead connection and tear it down — the recurring "mobile disconnects under load" report. A shared `mainLoopStalledWithin()` signal (off the main-process stall detector) now makes all three **freeze-aware**:

- **Server keepalive ping** — during a known freeze it **re-pings instead of terminating** the client (the pong is buffered-but-unprocessed, not dead). A genuinely dead socket is still reaped on the first healthy tick / by the OS TCP timeout.
- **Watchdog** — a probe failure that coincides with a freeze does **not** count toward the 3-strike restart, so a load storm never triggers a needless restart that drops every device (a real wedge, with the loop responsive, still restarts).
- **Reconnecting… strip** — shown only once an outage is 2 seconds old, or 1.5 seconds into a resume (above), so a sub-second blip never flashes it.

Kill switch `AMC_DISABLE_WS_STALL_GUARD=1`. Invariants: [/.claude/memory/contracts/mobile-ws-stall-guard-contract.md](/.claude/memory/contracts/mobile-ws-stall-guard-contract.md).

### Account sign-in on mobile (Global Auth gate)

Separate from the web-access **token** above, Omniscio can also show a Firebase-backed **"Sign in to continue"** account wall before the app loads (the Global Auth gate — default-staged, off unless an install turns it on). On a phone this needs care, because the gate's Google/GitHub sign-in is a **desktop** OAuth flow: it opens a browser on the computer running Omniscio with a desktop-side loopback callback, which a phone can't complete (the mobile WebSocket drops before the desktop flow returns — a 129 s hang in the reported incident). So on a web/mobile client the gate **doesn't offer the sign-in buttons** — it shows _"Sign in from the desktop app — this device will sign in automatically once you do."_ The mobile client shares the desktop's cached identity, and the `GLOBAL_AUTH_CHANGED` push (forwarded via `INBOX_ALLOWED_CHANNELS`) flips the phone to signed-in the moment the desktop signs in, no reload. `GLOBAL_AUTH_LOGIN` / `GLOBAL_AUTH_LINK_PROVIDER` are refused over the WS bridge (`BLOCKED_CHANNELS`) as a backstop, so a stray tap can never silently open a desktop browser.

A second mobile-specific wrinkle the same change fixes: the renderer holds a brief neutral splash until the first auth-status check returns, instead of optimistically painting the inbox and then swapping to the gate — on a phone that swap was a visible "inbox flash → sign-in screen." Full invariants + tests: [global-auth-gate-contract.md](../../.claude/memory/contracts/global-auth-gate-contract.md).

### When the desktop CAN'T sign in either (the silent dead state — fixed 2026-08-25)

There is a third case, and until 2026-08-25 it was completely silent. If the desktop install **requires** sign-in but was built with **no sign-in configuration**, the gate goes _inert_ — `isGateActive` returns false, so no sign-in wall renders and there is nothing to tap. Approving a phone for mobile access needs an account, so the phone is refused on every request while the desktop inbox stays empty and the app says nothing. The reported symptom was exactly that: Mobile Access enabled, phone fully locked out, no explanation anywhere.

The gate still goes inert (making it active would wall the app behind a sign-in screen that can never succeed), but it is **no longer silent**: a startup check raises ONE deduped inbox row — _"Sign-in is not set up on this computer"_ — naming what is actually broken (approving your phone, cloud sync, sharing) and the real remedy. It clears itself once sign-in works.

Who hits it: **not** a customer on an official release — those builds are guarded to carry all four sign-in credentials. It is a **fresh developer clone with an unseeded secrets vault** (`npm run secrets:pull` now also warns loudly in that case) or a **local `npm run package:win`**, which deliberately ships `{}` so a developer's vault can't leak into a shippable app. Invariants: [global-auth-gate-contract.md § NEVER-SILENT](../../.claude/memory/contracts/global-auth-gate-contract.md) (G-NS1…G-NS6); history: [signin-dead-state-postmortem.md](../../.claude/memory/postmortems/signin-dead-state-postmortem.md).

### Surviving ANY freeze: off-loop transport (Tier 2, default ON)

The stall-aware fix above keeps a freeze from being _mistaken_ for a dead connection, but the listener + its keepalive still run **on** the main loop — so a freeze long enough still risks the phone giving up. The durable fix moves the whole web/WS listener **off** the main loop into a Node **`worker_thread`** with its own event loop, behind `AMC_WS_OFFLOAD` (env) / `wsOffloadEnabled` (the **Settings → Remote Access → Advanced → "Freeze-proof mobile connection"** toggle), **default ON** since 2026-08-28 — existing installs are brought onto it by a one-shot migration, and the toggle stays the off-switch. While the main loop is frozen on disk I/O, the worker keeps answering the phone's app-level ping **locally** — the connection stays up and streaming just pauses then resumes, instead of the phone self-killing its socket after ~20 s of no pong.

- **It's a single-port proxy.** Tailscale (private `serve`, or public Funnel) exposes one URL → one port, so the worker OWNS that port (http + the `ws` server) and forwards data inward. The worker answers `/health/live` + ping/pong and serves static assets locally; it forwards every WS `invoke` (and the push-subscribe / token-touch DB writes) to main, which runs the actual IPC handlers and relays the reply.
- **The security boundary doesn't fork.** The token compare, CSWSH origin check, DNS-rebinding host-allowlist, idle-expiry, the per-IP brute-force lockout, and the blocked-channel / throttle / outbound-sanitize dispatch policy are the **same code** the in-main path uses (shared worker-safe modules — [web-access-guards.ts](/src/main/services/web/web-access-guards.ts) + the reused `web-access-ws` policy), so the worker can't drift into a weaker boundary.
- **Mobile access is never lost.** If the worker fails to spawn or dies unexpectedly, the transport falls back to the in-main server. The path is chosen at startup — flipping the flag needs a restart. A real-device freeze smoke is the gate before the default flips ON. Bonus: the worker's `/health/live` also hits the never-frozen worker, curing the Tier-1 watchdog false-positive.

Invariants: [/.claude/memory/contracts/ws-offloop-contract.md](/.claude/memory/contracts/ws-offloop-contract.md). Built on the `worker_thread` host pattern (lifecycle mirrors the v2 plugin worker), NOT a `utilityProcess` — the freezes are I/O-wait, not a main crash, so an own-loop thread is sufficient and lower-risk.

### Visibility-resume + active-session preservation

When the page hides for more than 2 seconds (e.g. the mobile file picker takes over the screen, the user switches tabs, or the OS backgrounds the browser), Omniscio's resume handler ([/src/renderer/src/lib/ipc.ts](/src/renderer/src/lib/ipc.ts) `triggerResumeRefresh`) tears down the existing WebSocket and reconnects synchronously — see **Reconnect speed (page resume + backoff)** above for the timing semantics. Once the new WebSocket is open, the reconnect handler in [/src/renderer/src/App.tsx](/src/renderer/src/App.tsx) re-runs `WEB_BOOTSTRAP`, and `useSessionStore.getState().hydrateMinimal(...)` replaces the sessions array with the fresh active-only payload. **There's a subtle catch**: `listActiveSessions()` excludes statuses `ready` and `terminating` (rare/ephemeral on desktop, where Phase 2 backfill picks them up). Without protection, a brand-new no-prompt session — which transitions `starting` → `ready` immediately and is the very session the user is currently attaching a file to — would vanish from state on reconnect, orphaning `activeSessionId` and rendering "No session selected". To prevent that, `hydrateMinimal` upserts the active session: if `activeSessionId` is set and the incoming bootstrap doesn't include it, the existing in-memory session row is appended. This keeps the bootstrap payload tight while making mobile attach-to-new-session reliable. See [postmortem](/.claude/memory/postmortems/mobile-attach-new-session-orphan-postmortem.md).

### Settings keys (reference)

| Key                  | Default   | Notes                                                                                                      |
| -------------------- | --------- | ---------------------------------------------------------------------------------------------------------- |
| `webAccessEnabled`   | `false`   | Master switch for the HTTP+WebSocket server                                                                |
| `webAccessPort`      | `9876`    | Range `1024–65535`; shared by the local URL and the Tailscale address                                      |
| `webAccessToken`     | auto-UUID | Token-gates every request                                                                                  |
| `tunnelEnabled`      | `false`   | Auto-start Tailscale access when web access starts; persists across app restarts                           |
| `tunnelPublicAccess` | `false`   | Public (Funnel) instead of private (`serve`); an install already using the public tunnel is seeded `true` once |

These five keys live in `config.json`; only `webAccessPort` is written via the standard settings IPC. The other four are mutated through dedicated IPC handlers (`WEB_ACCESS_START` / `WEB_ACCESS_STOP`, `TUNNEL_START` / `TUNNEL_STOP`, and `TUNNEL_SET_PUBLIC_ACCESS` for the public switch, which is refused over the phone bridge and has no CLI route). There is no dedicated regenerate-token channel — regeneration is `WEB_ACCESS_STOP` followed by `WEB_ACCESS_START` with a fresh token (see `handleRegenerateToken` in the settings UI).

## Related

- [Mobile Access](mobile-remote-access.md) — the overview page this continues.
- [Mobile Access part 3](mobile-remote-access-part-3.md) — the troubleshooting half.
- [offline-banner.md](offline-banner.md) — the desktop version of the same "is it my network?" signal.

