diff --git a/cortex/llm_client.py b/cortex/llm_client.py
index 6f0cc4d..dfd39a6 100644
--- a/cortex/llm_client.py
+++ b/cortex/llm_client.py
@@ -4,6 +4,7 @@ import os
import signal
import subprocess
from config import settings
+import event_bus
logger = logging.getLogger(__name__)
@@ -48,7 +49,10 @@ async def complete(
response = await _dispatch(primary, system_prompt, messages, model)
return response, primary
except Exception as e:
+ err_str = str(e)
logger.warning("%s failed (%s) — falling back to %s", primary, e, fallback)
+ if primary == "claude" and any(k in err_str for k in ("401", "authenticate", "expired", "OAuth")):
+ await event_bus.publish({"type": "claude_auth_expired"})
response = await _dispatch(fallback, system_prompt, messages, None)
return response, fallback
diff --git a/cortex/routers/auth.py b/cortex/routers/auth.py
index b234453..b8f2e11 100644
--- a/cortex/routers/auth.py
+++ b/cortex/routers/auth.py
@@ -20,7 +20,8 @@ router = APIRouter(prefix="/auth")
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
-WARN_HOURS = 24
+WARN_HOURS = 24 # no refresh token — warn a day ahead
+WARN_HOURS_REFRESH = 1 # refresh token present — only warn if CLI hasn't rotated in time
def _claude_status() -> dict:
@@ -31,11 +32,13 @@ def _claude_status() -> dict:
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
now = datetime.now(tz=timezone.utc)
hours_remaining = (expires_dt - now).total_seconds() / 3600
- # If a refresh token is present the session is long-lived (~1 year).
- # expiresAt only reflects the current access token window (~8 h) and
- # rotates automatically — do not warn based on it when a refresh token exists.
- warning = not has_refresh and hours_remaining < WARN_HOURS
- expired = hours_remaining <= 0 and not has_refresh
+ # When a refresh token is present the CLI *should* auto-rotate the access
+ # token, but sometimes it doesn't. Use a tight 1-hour window so a fresh
+ # 8-hour token doesn't immediately trigger a warning, but a stale token
+ # that the CLI missed will still surface before it expires.
+ expired = hours_remaining <= 0
+ threshold = WARN_HOURS_REFRESH if has_refresh else WARN_HOURS
+ warning = expired or hours_remaining < threshold
return {
"ok": True,
"has_refresh_token": has_refresh,
diff --git a/cortex/static/app.js b/cortex/static/app.js
index 2fadc7f..08b67d1 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -975,6 +975,20 @@
let data;
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') return;
+
+ if (data.type === 'claude_auth_expired') {
+ let banner = document.getElementById('claude-auth-banner');
+ if (!banner) {
+ banner = document.createElement('div');
+ banner.id = 'claude-auth-banner';
+ banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;';
+ banner.innerHTML = '⚠️ Claude authentication expired — run claude in your terminal to re-authenticate, then reload.'
+ + '';
+ document.body.prepend(banner);
+ }
+ return;
+ }
+
if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) {
@@ -1188,7 +1202,7 @@
warnings.push('✕ Claude CLI token has expired');
anyExpired = true;
} else {
- warnings.push(`⚠ Claude CLI token expires in ${d.claude.hours_remaining}h`);
+ warnings.push(`⚠ Claude CLI token expires in ${d.claude.access_token_hours_remaining}h`);
}
fixes.push('claude');
}