Remote Control auto-start reports "re-login required" when the OAuth refresh simply hasn't landed yet (credential still renewable)
Summary
With remoteControlAtStartup: true, the first session started after the access token has expired (i.e. the first launch of the day) reliably prints:
Remote Control disconnected — Claude.ai login expired — run /login to restore Remote Control
The login is not expired. The refresh token is valid for weeks. Running /remote-control in the same session, seconds later, connects successfully with the very same credentials — no /login needed.
The startup guard treats "the refresh has not completed yet" as "the credential cannot be refreshed", and prints the expensive remedy (interactive /login) instead of the cheap one (retry / /remote-control).
Environment
- claude-code 2.1.260 (global npm install,
bin/claude.exe) - Windows 11 Home 10.0.26200
- Entrypoint: VS Code extension (also reproduces from the terminal)
- Account: Pro, scopes include
user:sessions:claude_code
Related issues — why this is not a duplicate
The same [bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required) line already appears in existing reports, but always with a genuinely dead credential:
- #90688 (Windows/VS Code, forced
/loginafter every sleep/wake) logs this exact line —[bridge:sdk] State change: failed - /login,Remote Control auto-enable failed: Error: /login— but there the refresh token had been revoked server-side (HTTP 400, refresh-token reuse detection). The/loginwas genuinely required. - #91708 (Windows/VS Code) documents concurrent processes racing on the OAuth refresh and invalidating the token family. Again a real credential death.
- #88951 (macOS) is the same startup guard failing, but with OAuth genuinely unhealthy (a shadowing keychain entry) and no user-visible signal at all.
This report is the inverse case: the credential is perfectly healthy and renewable, and the guard declares it dead anyway. The proof is that /remote-control, run seconds later in the same session with the same credential file untouched, connects successfully.
That difference is the point. Today the message is indistinguishable between the fatal case (#90688) and the benign one, so a user cannot tell "your login is really gone" from "the refresh just hasn't landed yet, retry". Both print run /login, and in the benign case that advice costs an unnecessary interactive re-authentication every single morning. Fixing #90688 and #91708 removes some causes of a real refresh failure; it does not stop this guard from misreporting a transient one.
The guard
From the 2.1.260 bundle (minified, names as shipped):
await ys({credentials: F, storageV5: B}); // attempt the OAuth refresh
let n = E3t(); // re-read credentials.expiresAt
if (n !== null && n <= Date.now()) {
Yb("oauth_expired_unrefreshable",
"[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)");
G?.("failed", Hve, "auth"); // surfaces the "/login" banner
let d = n;
return await we((p) => ({...p,
bridgeOauthDeadExpiresAt: d,
bridgeOauthDeadFailCount: p.bridgeOauthDeadExpiresAt === d
? (p.bridgeOauthDeadFailCount ?? 0) + 1 : 1}), B), null;
}
ys() can return without having refreshed anything for purely transient reasons — contention on ~/.claude/.oauth_refresh.lock (OAuthRefreshLockContendedError, stale window 60s) when more than one session starts at once, or a slow/unavailable token endpoint at boot. The guard cannot tell those apart from a genuinely dead credential: it only observes that expiresAt is still in the past.
Note that #90688's logs show the very next line after this guard is [bridge:sdk] State change: failed - /login and Remote Control auto-enable failed: Error: /login, i.e. the "auth" classification passed to G?.() is what turns a possibly-transient condition into a terminal, login-demanding state for the whole session.
Why this is a misdiagnosis, not a real auth failure
The predicate needed to distinguish the two cases is defined immediately adjacent to the one the guard uses, and is never consulted here:
function E3t(){ return qt()?.expiresAt ?? null } // used by the guard
function pUe(){ return qt()?.subscriptionType ?? null }
function FAn(){ return qt()?.refreshToken != null } // never consulted by the guard
Concrete data from an affected machine:
.claude.json bridgeOauthDeadExpiresAt = 1788573991415 -> 2026-09-05 04:06:31 (recorded dead)
.credentials.json accessToken expiresAt = 1788642627089 -> 2026-09-05 23:10:27
refreshToken expiresAt = 1791042620089 -> 2026-10-03 17:50:20 (valid for 4 more weeks)
The access token has an ~8h TTL and only gets refreshed while Claude Code is running, so it always goes stale overnight. Every morning the bridge races its own refresh, loses, and declares the login dead — while the refresh token is nowhere near expiry.
The same bridge already distinguishes these cases elsewhere
The proactive refresh cycle classifies the outcome three ways:
C = { leg: "bad", code: Xe ? "oauth_rejected_after_refresh"
: re ? "oauth_rejected_refresh_failed"
: "oauth_rejected_no_refresh_path" };
oauth_rejected_no_refresh_path is precisely "there is no way to renew this". The startup guard collapses all three into oauth_expired_unrefreshable, which is why the wrong message reaches the user. This inconsistency inside the same component suggests an oversight rather than a deliberate choice.
Why it costs users repeated logins
/login mints fresh tokens, so the bridge starts and the banner goes away — the wrong remedy appears to work. Users conclude that Remote Control genuinely requires a daily interactive re-login, and keep paying for it, when a retry would have sufficed.
The fail-count backoff does not mitigate it
if (e.bridgeOauthDeadExpiresAt != null && (e.bridgeOauthDeadFailCount ?? 0) >= 3
&& E3t() === e.bridgeOauthDeadExpiresAt) return skip;
The counter increments only when the same expiresAt fails again (p.bridgeOauthDeadExpiresAt === d ? count+1 : 1). Each morning brings a new stale expiry, so it resets to 1 and the banner repeats indefinitely. It only reaches 3 if several sessions are launched in the same morning — in which case Remote Control is skipped silently, with no banner at all, which is arguably a second problem.
Steps to reproduce
- Set
remoteControlAtStartup: trueat user scope. - Use Claude Code, then leave it closed for more than 8 hours (overnight), so the access token expires while nothing is running.
- Start a session (starting two at once makes it far more likely, via the refresh lock).
- The banner appears, pointing at
/login. - Type
/remote-controlin that same session — it connects, with the credentials that were just declared dead.
Suggested fix
In the startup guard, before reporting an auth failure, check whether a refresh path exists — FAn() / a non-expired refreshToken:
- Refresh token present and unexpired → transient. Do not surface an auth failure, do not record
bridgeOauthDead*. Retry on the existing schedule, or fall through to the same path/remote-controluses (which already succeeds at this point). - No refresh token, or refresh token expired → the current behaviour is correct;
/loginis genuinely required.
Distinguishing lock contention (OAuthRefreshLockContendedError) from a rejected refresh inside ys() would make this exact, and would also fix the case of several sessions starting simultaneously.
If retaining a user-visible message in the transient case is preferred, "Remote Control couldn't start yet — run /remote-control to connect" would point at the remedy that actually works.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗