Remote Control silently self-disables after 3 transient failures and never recovers (per-session init latch + persisted OAuth dead-token backoff)
Environment
Claude Code 2.1.212, Windows 11 Pro (26200), claude.ai Max subscription (OAuth login, no API key, no ANTHROPIC_BASE_URL override, no disableRemoteControl in any settings file).
Summary
Remote Control has two independent three-strike latches that disable it silently and permanently, with no persistent log of why. Both are armed by transient conditions (a network blip or an expired-token moment at session startup), but neither clears when the underlying condition recovers. The result from the user's side is the one in the title of every "remote control is flaky" report: some sessions show up at claude.ai/code and some just never do, with nothing to read afterwards explaining the difference.
The two mechanisms are separable, but they share a root pattern — a transient failure is treated as permanent — so I'm filing them together.
I derived the control flow by reading strings and surrounding code out of the shipped binary, so the minified identifiers below (HRb, uIs, v1e) are build-specific to 2.1.212 and are offered only as pointers into the source.
Mechanism 1 — per-session: 3 consecutive init failures kill Remote Control for the life of the process
The auto-mirror hook counts consecutive bridge init failures and, at HRb = 3, gives up on the session permanently:
HRb = 3
nMt = "disabled after repeated failures · restart to retry"
if (_.current >= HRb) {
T(`[bridge:repl] Hook: ${_.current} consecutive init failures, not retrying this session`)
He(nMt)
L((_t) => ({ ..._t, replBridgeError: nMt, replBridgeEnabled: false, replBridgeSessionGroupingId: undefined }))
return
}
Once this fires there is no backoff-and-retry and no re-arm on network recovery — the only stated remedy is the message's own "restart to retry". A long-lived session that happened to start during a few seconds of bad connectivity is excluded from Remote Control for hours afterwards, long after connectivity is fine.
The warning is surfaced in the TUI (Remote Control failed · disabled after repeated failures · restart to retry), but it's a transient notification early in a session that is typically scrolled away by the time the user reaches for their phone.
Mechanism 2 — machine-wide: bridgeOauthDeadFailCount >= 3 skips the bridge for every new session, without even trying
In the mirror precondition chain:
if (!v1e()) {
let Ue = At();
if (Ue.bridgeOauthDeadExpiresAt != null && (Ue.bridgeOauthDeadFailCount ?? 0) >= 3 && b6n() === Ue.bridgeOauthDeadExpiresAt)
return T(`[bridge:repl] Skipping: cross-process backoff (dead token seen ${Ue.bridgeOauthDeadFailCount} times)`), null;
await N_();
let Ye = b6n();
if (Ye !== null && Ye <= Date.now()) {
wce("oauth_expired_unrefreshable", "[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)")
S?.("failed", tLt);
let rt = Ye;
return await cr((Xe) => ({
...Xe,
bridgeOauthDeadExpiresAt: rt,
bridgeOauthDeadFailCount: Xe.bridgeOauthDeadExpiresAt === rt ? (Xe.bridgeOauthDeadFailCount ?? 0) + 1 : 1
})), null
}
}
v1e() is function v1e(){return} in this build, so !v1e() is always true and this path is evaluated on every session start.
Three starts that find an expired-and-unrefreshable access token persist bridgeOauthDeadFailCount: 3 into ~/.claude.json. From then on, while the stored credential keeps that same expiresAt, every newly started session returns null from the precondition immediately — no refresh attempt, no connection attempt, no TUI warning, because this early return skips the S?.("failed", ...) notifier that the sibling branches call. The session simply never appears on the phone and the user is given nothing at all.
The trigger is easy to hit unintentionally: the access token here has an ~8-hour lifetime, so a machine that sleeps across the expiry boundary and wakes with a cold network is exactly the shape that produces a run of three unrefreshable starts.
Evidence from a live machine
~/.claude.json still carries a fully-armed latch from a past incident:
bridgeOauthDeadExpiresAt = 1782111997120 -> 2026-06-22 09:06:37
bridgeOauthDeadFailCount = 3
That is Mechanism 2 having fired to completion: the "expired and refresh failed" branch was reached three consecutive times against the same token. (It is dormant now only because the live credential has since been re-minted with a different expiresAt; the record itself never gets cleaned up.)
Mechanism 1 reproduced on its own while I was investigating. Three concurrent interactive sessions, identical config, healthy unexpired token — and one of them has no bridge at all, per ~/.claude/sessions/<pid>.json:
pid=<A> v=2.1.212 kind=interactive status=busy bridgeSessionId=session_01... <- on phone
pid=<B> v=2.1.212 kind=interactive status=busy (no bridgeSessionId) <- never appears
pid=<C> v=2.1.211 kind=interactive status=idle bridgeSessionId=session_01... <- on phone
Session B started ~12 minutes before session A, inside the same valid-token window, from a clean working directory with no project-level settings. It has been running for ~20 minutes and will never self-heal.
There is no log to diagnose this from
Every branch above reports through T(...) / wce(...), which only land in debug logging. With debug off (the default), a permanently-disabled bridge writes nothing to disk. ~/.claude/debug/ is empty and ~/.claude/daemon.log covers the background-agent supervisor, not the bridge — so a user hitting this has no artifact to inspect or attach to a bug report. This is a large part of why the failure reads as "random".
Proposed repro (derived, not executed)
I don't have a deterministic repro — I found this from persisted state rather than by inducing it, and I didn't want to sever connectivity on a working machine. From the code path the following should reproduce Mechanism 1:
- Start an interactive
claudesession with outbound access toapi.anthropic.comblocked or heavily lossy. - Let bridge init fail three times.
- Restore connectivity fully and confirm the account is healthy.
- The session never returns to Remote Control for the rest of its life; a freshly started session in the same directory connects immediately.
For Mechanism 2, forcing three session starts against an expired access token whose refresh fails should persist bridgeOauthDeadFailCount: 3 to ~/.claude.json and then silently skip the bridge on all subsequent starts.
Suggested fixes
- Retry instead of latching. Replace both permanent give-ups with bounded exponential backoff that keeps retrying for the life of the session. A network blip at second 3 shouldn't cost the user Remote Control at minute 90.
- Clear the counters on success.
bridgeOauthDeadFailCountshould reset whenever a token refresh succeeds or a bridge init succeeds, rather than persisting until the credential'sexpiresAthappens to change. The stale June record above is evidence it's never cleaned up. - Make Mechanism 2 visible. The cross-process-backoff early return skips the
S?.("failed", ...)notifier, so unlike its siblings it produces no TUI warning at all. It should report through the same path. - Log skip reasons unconditionally. These four outcomes (
not_enabled,no_oauth,policy_denied,oauth_expired_unrefreshable, plus the backoff skip) are low-volume, once-per-session, and are the entire diagnostic story. Writing them somewhere durable regardless of debug mode would make every future "remote control is flaky" report self-diagnosing. - Re-evaluate
/remote-controlas an escape hatch. Worth confirming that the slash command can force re-init past a latchedreplBridgeEnabled: false, and stating so in the failure message if it can.
Related, but distinct
- #77282 — eligibility check races the profile fetch,
--rcignored. Same symptom class (silently absent from claude.ai/code, never re-evaluated) but a different gate: that one never gets past eligibility, these two pass eligibility and then latch off. - #63470 — TLS interception by HTTPS-scanning AV. A plausible cause of the repeated init failures that arm Mechanism 1; the latch is what makes it permanent.
- #76748 —
/remote-controlhidden whenDISABLE_TELEMETRYis set. Different gate.