fix(chat): recover from an expired session instead of dead-ending on it - #13
Conversation
alonmuroch
left a comment
There was a problem hiding this comment.
Reviewed the diff, ran the repo's gates locally in a scratch worktree, and traced the auth paths. Excellent writeup — the baseline table and the "deliberately not a pre-send check" reasoning made this fast to review. Gates below are all green; one defect needs fixing before this lands.
Blocker — the probe is vendor-blind, and it locks Codex users out entirely
probeAuth() (src/routes/+page.svelte:7809) calls api.checkAuthStatus(). That command checks only the Anthropic API key plus check_cli_oauth() (src-tauri/src/commands/onboarding.rs:18), and check_cli_oauth (onboarding.rs:733) shells out to the claude binary. It is Anthropic-specific by construction.
But the app is multi-vendor — currentProvider is derived from agentFor(model) === "codex" (:416), and SetupWizard.svelte:69 already branches at exactly this point, with the comment "Codex auth lives in codex login status, not the Anthropic-specific auth_mode."
For a Codex/ChatGPT user who has never signed into the Claude CLI:
- Mount →
probeAuth()→signedOut = true→ the card renders "Your ChatGPT session expired." - Every send hits the new guard at
:7586and returns early — they cannot send at all. - Reconnect →
loginVendor()→loginCodex()succeeds → the next probe still asks Claude → signed out again → card returns. Permanent lockout, no in-app escape.
This inverts the PR's own fail-soft principle: the probe fails soft when it throws, but fails closed when it answers wrongly — and a wrong answer is the default for a whole vendor.
Fix is small — mirror the wizard (api.checkCodexAuth() is already exported at src/lib/api.ts:525):
const signedOut =
agentFor(model) === "codex"
? !(await api.checkCodexAuth()).logged_in
: await api.checkAuthStatus().then((s) => !s.has_oauth && !s.has_api_key);I verified this by tracing the code paths, not by running a Codex-only build — worth a quick confirmation on your side.
Also worth fixing
:7586 — the hard block runs on a cached verdict. Nothing re-probes at send time, so even after the vendor fix, a user who signs in by another route (a terminal claude auth login) stays blocked until a focus/visibility event or the 120s tick. Firing void probeAuth() before the return lets it self-heal in a tick instead of up to two minutes.
Deferred section is missing one: sessions already branded on v0.6.0 keep "Failed to authenticate: OAuth session expired and could not" forever. The guard only prevents new ones. Worth stating so it doesn't read as an oversight.
Nits
:7840takeReconnectIntent()reads without consuming — the name promises otherwise, and both call sites pair it with a separateclearReconnectIntent().hasPendingReconnectIntent()would stop the next reader assuming it consumed.:9762the cleanup block clearsauthProbeTimerbut notauthReconnectedTimer, andpollAuthWhileReconnecting()(:7907) leaks six uncleared timeouts. Harmless in Svelte 5, but inconsistent with the siblings right there."session expired"inAUTH_EXPIRY_STRICT_MARKERSis broad enough to reject a legitimate title ("Session expired handling in Redis"). Benign — the fallback is the prompt-derived name — but the doc comment claims more strictness than the list delivers.
Gates (run locally in a scratch worktree)
| Gate | Result |
|---|---|
eslint |
pass — 0 errors, 32 pre-existing warnings |
prettier (changed files) |
pass |
svelte-check |
1 error — vite.config.ts:4 Cannot find name 'process', pre-existing, exactly as you describe. Warn-mode in CI; this PR adds no new errors |
i18n:check |
pass — 0 errors, 18 pre-existing warnings |
vitest |
pass — 1704 tests / 67 files, matches your number exactly |
vite build |
pass |
| Rust gates | n/a — no Rust files |
Non-vacuous check: verified. Removed if (isAuthExpiryText(text)) return null; and re-ran — 2 of the 3 new tests fail, including the exact branding string, while "keeps titles that merely mention auth" still passes. The guard does real work and the false-positive test isn't tautological.
Your --no-verify note checks out: .githooks/pre-commit step 4 runs a project-wide svelte-check --threshold error, which the pre-existing vite.config.ts error fails — it blocks every commit touching .ts/.svelte. Sending the @types/node one-liner separately is the right call.
Security / compatibility
No new IPC command (reuses check_auth_status), no new outbound request, no injection surface. sessionStorage holds a timestamp only — the stashed prompt stays in memory and is never persisted, which is the right choice. Rejecting auth-error titles also keeps a vendor error string out of persisted, displayed session names. No trust boundary widened; the one regression is availability (fail-closed), i.e. the blocker above.
Backwards compatible. isAuthExpiryText is additive alongside the unchanged isAuthExpiryError; the new sessionStorage key's absence is the normal path; no wire, config, or API change.
Housekeeping
Squash 832b0c3 into 2c042e3 — both are "survive the remount" and the first is superseded by the second.
Heads-up on ordering: #13, #6, #2 and #3 all edit src/routes/+page.svelte. This one is the smallest and most self-contained, so landing it first minimises rebase pain on the other three.
|
Blocker confirmed and fixed — thanks, that was a bad one and your read of it was exact. Verified before fixing, since you asked: Your framing of why it was bad is the part I'd internalised wrongly — the probe fails soft when it throws but was failing closed when it answered wrongly, and wrong was the default for a whole vendor. Fixed by branching on the vendor the way the wizard does, extracted into Also fixed:
One thing I did not do: I haven't run a Codex-only build either, so the vendor branch is verified by code path and types, not by driving a ChatGPT-only install. Worth someone with that setup confirming before merge. |
alonmuroch
left a comment
There was a problem hiding this comment.
Re-reviewed the whole diff at 56b69a4, not just the deltas. Every item from the last round is properly addressed, and two of them better than I asked:
isVendorSignedOut()mirrorsSetupWizardcorrectly and stays inside the try/catch.- The refused-send self-heal actually fires — I checked that
sending = trueisn't set until:7639, after the guard at:7589, soprobeAuth()'sif (!isApp || sending) returndoesn't swallow it. pollAuthWhileReconnectingnow also cancels a prior series, which I hadn't asked for and which kills the double-click stacking bug.- The
AUTH_EXPIRY_STRICT_MARKERSdoc is honest about the "Session expired handling in Redis" case now.
Gates re-run locally on this head: eslint 0 errors, prettier clean, vitest 1704/1704 (67 files), vite build pass.
One thing left, and it's the same failure class as the vendor bug wearing a different hat.
The documented fail-soft doesn't actually hold
probeAuth (:7830) says:
Fail-soft: only a definite "signed out" answer trips the card. A probe that throws (CLI missing, timeout) is ignored — never block a user who could actually send.
The catch is real, but nothing reaches it. Neither backend ever returns Err for those cases:
check_codex_auth(src-tauri/src/commands/diagnostics.rs:83) returnsOk(CodexAuthResult { logged_in: false, … })for codex not installed (:89), exec error (:131), andcodex login statustimed out after 12s (:141).check_auth_status(onboarding.rs:18) has no fallible call at all — it always returnsOk, andcheck_cli_oauthmaps a timeout or a non-zero exit to(false, None).
So every case the comment promises is "ignored" actually arrives at :7833 as a confident signedOut = true — which raises the card and hard-blocks every send at :7589. The catch only covers an IPC-layer failure, which is the rare case.
Usually this self-heals now (the refused-send re-probe you added is what saves it). What worries me is the machine where it doesn't: if codex login status consistently exceeds 12s — a slow disk, or an AV scanning node.exe on every spawn, which is precisely the SentinelOne setup from the Windows PR — the probe returns "signed out" every time. Card permanently up, every send refused, Reconnect doesn't help because the next probe times out again. That is the same lockout shape as the vendor bug, just triggered by slowness instead of vendor.
Options, cheapest first
- Require two consecutive signed-out probes before raising the card. Two lines, kills every transient false positive, costs one extra probe interval before a genuinely signed-out user sees the card. Given the probe already runs on mount/focus/visibility/tick, that delay is small.
- Distinguish "signed out" from "couldn't tell" at the boundary.
CodexAuthResultalready carries the evidence —installed: false,status_text: "exec error: …"/"timed out (12s)". Treat those as indeterminate rather than signed-out. Cleaner, but the Anthropic side would need the same signal added toAuthCheckResultto be consistent. - Decouple the card from the block: let the probe raise an advisory card, and keep the hard send-refusal for
authExpiredraised by an actual turn failure viahandleTurnError— i.e. only block when a send has genuinely failed. Best separation of "we know" from "we think", but the largest change.
(1) is enough for me and keeps the PR small. Whichever you pick, the doc comment on probeAuth should describe what the code does — right now it describes a guarantee the backends don't provide, which is the kind of thing that survives review a second time precisely because it's written down.
Everything else is ready. Once this is in I'm happy to approve.
|
Verified the backends before touching anything — you're right on all three, and the comment was the worst part of it:
So the One push-back on option 1, and it changed what I built. Two strikes only fixes the transient case. The machine you were actually worried about —
Doc comment now describes the actual behaviour, including which backend returns what — you're right that writing the guarantee down is why it survived the first review. Gates on Not verified live: the override path and the indeterminate branch are reasoned, not driven — reproducing a persistent 12s CLI timeout needs a machine I don't have. 🤖 Addressed by Claude Code |
…ssion
A session is titled by a throwaway CLI run whose only job is to name it.
When that run fails on expired auth it returns the failure text, and
sanitizeGeneratedTitle accepted it - one line, 72 chars, 11 words clears
every filter - so the session was branded
"Failed to authenticate: OAuth session expired and could not" forever.
Observed on a real install; that title is character-for-character a
60-char truncation of the error.
Reject auth-failure text as a title and keep the prompt-derived one.
The check is a new STRICT matcher, not the existing isAuthExpiryError:
that one deliberately matches bare "authenticate" and "unauthorized" so
raw errors are caught generously, but a title is ordinary content and a
false positive there silently discards a good name. The strict list holds
only phrasings a CLI emits about its own OAuth, so
"Fixing the unauthorized API error" still titles a session correctly.
Committed with --no-verify: the pre-commit hook runs a project-wide
svelte-check, which fails on a pre-existing error in vite.config.ts
("Cannot find name 'process'") unrelated to this change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signing out was only discovered by sending: the prompt was consumed, the
vendor CLI answered with its own auth failure, and that failure rendered
as the assistant's reply - a dead-end bubble with no way to recover. A
long, carefully written prompt was lost to a state that was knowable
before a key was pressed.
Probe the vendor CLI's sign-in in the background (startup, window focus,
visibility, and a 2-minute idle tick) and raise the existing
"Session expired -> Reconnect" card as soon as it reports signed out. A
send while that card is up is refused instead of spent, and the text is
handed back to the composer.
Deliberately NOT a pre-send check: {
"loggedIn": false,
"authMethod": "none",
"apiProvider": "firstParty"
} spawns a process
and measures ~700ms on this machine, so gating every send would tax every
message to catch a rare state. Probing in the background keeps sends
instant and, better, shows the user they are signed out BEFORE they type.
The guard runs before any turn state is mutated. Placed later it would
strand a user bubble in the transcript with no reply, because the message
is appended a few lines further down.
Fail-soft throughout: only a definite signed-out answer raises the card;
a probe that throws (CLI missing, timeout) is ignored, so a user who
could actually send is never blocked.
Committed with --no-verify: the pre-commit hook's project-wide
svelte-check fails on a pre-existing vite.config.ts error unrelated to
this change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Testing the recovery card end to end surfaced the tail of the problem: clicking Reconnect tore the card down immediately, so a completed login and one that silently failed looked identical - the card vanished, the prompt reappeared in the composer, and nothing ever said the app was signed in again. Hold a reconnecting state instead of clearing on click. The button reads "Waiting for sign-in..." and stays disabled while the browser flow is out; the background probe reports the outcome, since loginVendor() is fire-and-forget but returning from the browser fires window focus, which runs the probe within a tick. Success shows a short green "Reconnected to Claude - your message is back in the composer" note; still signed out re-raises the expired card. Also fixes a stale-card bug in the probe: clearing was gated on there being no stashed prompt, which is exactly the case after a refused send, so a "session expired" claim could survive a successful reconnect. Clearing is now unconditional - the prompt is not lost, it was handed back to the composer when the send was refused. The confirmation only fires for someone who actually clicked Reconnect; an unprompted "Reconnected" note on every launch would be noise. Committed with --no-verify: the pre-commit hook's project-wide svelte-check fails on a pre-existing vite.config.ts error unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live test: the expired card appeared, the send was refused with the text kept, Reconnect signed in - and then the card just vanished with no confirmation. Two reasons, both invisible from the code alone: 1. The login flow finishes IN-PROCESS (run_claude_login -> its own check_auth_status) and fires no window focus event, so the observer I relied on never ran; the idle tick was two minutes away. 2. Signing in can swap the view and remount this component, dropping the in-memory authReconnecting flag - so even once a probe did run, nothing remembered that a human was waiting on the answer. Mirror the intent into sessionStorage so it survives a remount, and poll a short backing-off series of probes (1.5s..30s) after the click instead of waiting for focus. Whichever path resolves first wins; the probe clears the flag either way, so the confirmation fires exactly once. The stored intent is timestamped and expires after 10 minutes, so an abandoned login cannot congratulate the user on a later, unrelated launch. Committed with --no-verify: the pre-commit hook's project-wide svelte-check fails on a pre-existing vite.config.ts error unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mpted Video of a real reconnect showed the confirmation appearing only after the user switched tabs - i.e. only once some unrelated focus event happened to run a probe. The follow-up polling added for the in-process login was guarded by authReconnecting and scheduled on the component instance showing the card. Signing in can remount that component: those timers die with the old instance and the new one starts with authReconnecting=false, so the persisted sessionStorage intent sat there with nothing watching it. The only remaining triggers were focus, visibility, and the two-minute tick - hence the "only after switching tabs" behaviour. Pick the intent back up on mount: restore authReconnecting and restart the polling, so the confirmation fires on its own within seconds of signing in. Polling is now a named helper called from both the click and mount, and it runs one step longer (45s) to cover a slow browser round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cked out Review catch, and a bad one: check_auth_status is Anthropic-specific - it shells out to the `claude` binary - so probing it on behalf of a Codex user returns "signed out" no matter how healthy their ChatGPT login is. Because a raised card also refuses sends, that wrong answer locked Codex users out of the app completely: the card claimed "Your ChatGPT session expired", every send bailed at the guard, and Reconnect ran loginCodex() successfully only for the next probe to ask Claude again and re-raise it. No in-app escape. This inverted the feature's own principle. The probe fails soft when it THROWS, but it was failing closed when it answered wrongly - and wrong was the default for an entire vendor. Branch on the vendor the way SetupWizard already does, via the existing api.checkCodexAuth() (`codex login status`) instead of the Anthropic path. Also from review: - The send guard acted on a cached verdict, so someone who signed in by another route (a terminal `claude auth login`) stayed blocked until a focus event or the 120s tick. Re-probe as the guard trips so it self-heals. - takeReconnectIntent() read without consuming, which its name denied; renamed hasPendingReconnectIntent() and the contract spelled out. - The reconnect poll leaked six uncleared timeouts and authReconnectedTimer was not cleared on destroy. Both tracked and cancelled now, and a second Reconnect click replaces the poll series instead of stacking another. - AUTH_EXPIRY_STRICT_MARKERS claimed more precision than it delivers - "session expired" would reject a title like "Session expired handling in Redis". Documented the real tradeoff rather than the aspiration: a false positive costs only the generated title, so the list is tuned to never miss a real failure. Committed with --no-verify: the pre-commit hook's project-wide svelte-check fails on a pre-existing vite.config.ts error unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The comment on probeAuth promised fail-soft: "a probe that throws (CLI
missing, timeout) is ignored". Review checked the backends; nothing throws.
check_codex_auth returns Ok(logged_in: false) for codex-not-installed, an
exec error, and a 12s timeout alike, and check_auth_status has no fallible
call at all - a Claude CLI timeout or non-zero exit becomes has_oauth: false.
So every case the comment called "ignored" arrived as a confident signed-out,
raised the card, and hard-blocked every send. The catch only ever covered an
IPC fault, which is the rare case.
Same failure class as the vendor bug: not failing soft, failing CLOSED on a
wrong answer.
Three layers, because one is not enough:
1. Where the backend hands over evidence of "couldn't tell" rather than
"signed out" - codex `installed: false`, or a `status_text` naming a
timeout or exec error - the verdict is now INDETERMINATE and the probe
changes nothing.
2. Otherwise require two consecutive signed-out verdicts before raising the
card. A single slow spawn can no longer block anyone; a genuinely
signed-out user sees the card one probe later, and probes already run on
mount, focus, visibility and the idle tick.
3. The refusal is overridable ("Send anyway"). Layers 1 and 2 both fail on the
machine the review was actually worried about: the Anthropic path exposes
NO indeterminate signal, so if `claude auth status` is persistently slow -
an AV scanning node.exe on every spawn, i.e. the SentinelOne setup from the
Windows PR - every probe times out, both strikes land, and the user is
stuck for good. Two strikes fix the transient case only. An override is
what makes a wrong verdict impossible to get trapped by. The card stays up,
since the claim may be right; it just stops blocking. A real turn failure
re-blocks through handleTurnError, which is evidence rather than inference.
The doc comment now describes what the code does instead of a guarantee the
backends never made - that wording is why this survived a round of review.
Committed with --no-verify: the pre-commit hook's project-wide svelte-check
fails on a pre-existing vite.config.ts error unrelated to this change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9c967ae to
873bf7b
Compare
alonmuroch
left a comment
There was a problem hiding this comment.
Reviewed 873bf7b. The indeterminate/two-strikes/override combination is the right shape, and rewriting the probeAuth doc to describe what the backends actually do — rather than what we wished they did — is the part that will keep this correct a year from now.
Gates re-run on this head: eslint 0 errors, prettier clean, vitest 1704/1704 (67 files), vite build pass.
Two things before I approve.
1. authBlockOverridden is never re-armed on a signed-in verdict
It is set at :7947 and cleared only at :7953 (reconnectAuth). probeAuth's signed-in branch resets signedOutStreak but not this.
So after one "Send anyway" click:
- Override is on; the next send goes through and clears the card at
:7648. - Hours later the session genuinely expires.
- The probe raises the card again (two strikes, correctly).
- But
authBlockOverriddenis still true, so the guard at:7596is bypassed — the message is spent, and since this failure arrives as assistant content rather than an error event (your own baseline section:handleTurnError"never fired"), it lands as a dead-end error bubble.
That is precisely the bug this PR exists to fix, re-armed permanently by a single click on a button most likely to be pressed by someone whose CLI was merely slow.
One line, next to signedOutStreak = 0:
signedOutStreak = 0;
authBlockOverridden = false; // the situation resolved; re-arm the guardThe override then survives exactly as long as the verdict it was overriding, which is what the doc comment already promises ("A real turn failure re-blocks through handleTurnError").
2. Two strikes reopens the cold-start hole
if (signedOutStreak < 2 && !authExpired) return; — at launch only one probe runs (void probeAuth() on mount). The second comes from a focus/visibility event or the 120s tick.
So on a genuinely signed-out machine, for up to two minutes after launch there is no card. A user who opens the app and types straight away sends into the hole: the guard sees authExpired === null, the message is spent, and — per the same content-vs-error path above — nothing catches it. That window is v0.6.0 behaviour, and "the card appears before typing" is the promise in the PR title.
The fix keeps both properties: when the first signed-out verdict lands, don't wait for the tick to confirm it.
signedOutStreak += 1;
if (signedOutStreak < 2 && !authExpired) {
// Confirm promptly rather than waiting up to AUTH_PROBE_MS — the whole point
// is to warn before the user types.
authPollTimers.push(setTimeout(() => void probeAuth(), 3000));
return;
}Pushing onto authPollTimers gets it cancelled on destroy for free. ~3s costs nothing (the probe is ~700ms) and keeps a single slow spawn from ever raising the card, which is the property you added it for.
Nothing else. The installed: false / status_text sniffing at :7844 is the right use of the evidence the codex backend already carries, and gating the streak on !authExpired so a real handleTurnError bypasses it — evidence beating inference — is a nice touch.
Two ways the auth-expiry guard could still let a message fall into the dead end it exists to prevent. `authBlockOverridden` was set by "Send anyway" and cleared only by `reconnectAuth`. Nothing re-armed it on a signed-in verdict, so a single click - most likely from someone whose CLI was merely slow, not signed out - disabled the guard for the rest of the session. Hours later a real expiry would raise the card correctly and the send would still go through, spending the message. Because that failure arrives as assistant content rather than through `handleTurnError`, nothing catches it and it lands as a dead-end error bubble. Reset the override alongside `signedOutStreak` so it lives exactly as long as the verdict it overrode, which is what its doc comment already promised. The two-strike rule also reopened the cold-start hole it was added beside. At launch only one probe runs; the second arrives from a focus or visibility event, or the 120s tick. On a genuinely signed-out machine that left no card for up to two minutes - so a user who opened the app and typed immediately hit the same content-not-error path with nothing to stop them, which is the v0.6.0 behaviour this PR set out to fix. Schedule a 3s confirming probe on the first signed-out verdict instead of waiting for the tick. Both properties hold: the card is up before anyone finishes typing, and raising it still needs two verdicts, so one slow spawn cannot block a healthy machine. The timer goes on `authPollTimers` so the existing teardown cancels it. Gates: eslint 0 errors, prettier clean, vitest 1704/1704, vite build ok. Committed with --no-verify: the pre-commit hook's svelte-check leg fails on `vite.config.ts` needing @types/node, which reproduces identically on main and is untouched here. Prettier and eslint were run by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both fixed in 1. Override never re-armed. Taken as written — 2. Cold-start hole. Also taken as written, with the delay pulled out to a named One thing I verified rather than assumed, since the timer is scheduled from inside the probe: it can't stack. The push is gated on Gates on Note on the commit: it went in with 🤖 Addressed by Claude Code |
alonmuroch
left a comment
There was a problem hiding this comment.
Reviewed 860ceb8. Both asks are fixed, and correctly.
The override now expires with the verdict it overrode. authBlockOverridden = false sits next to signedOutStreak = 0 in the signed-in branch, so a "Send anyway" click can no longer disarm the guard past the moment it was needed. The comment explaining why — that a latched override would let the next genuine expiry spend a message into the assistant-content path that handleTurnError never sees — is the part worth having written down.
The cold-start hole is closed without giving up two strikes. The 3s confirm probe on a first signed-out verdict restores "the card is up before you finish typing" while a single slow spawn still can't raise it. Pushing it onto authPollTimers means it's cancelled on destroy and superseded by a Reconnect click for free. I checked it can't run away: a second signed-out verdict raises the card and stops scheduling, and once authExpired is set the branch is bypassed entirely, so it's at most one extra probe per sign-out transition.
Gates on this head: eslint 0 errors, prettier clean, vitest 1704/1704 (67 files), vite build pass.
Worth saying plainly, since it took four rounds: the thing that made this PR hard was never the code, it was that the backend cannot distinguish "signed out" from "the CLI didn't answer", and every early version quietly assumed it could. What's landing now names that limitation in the one place a future reader will be standing when it matters, and defends against it three different ways. That's a better outcome than the version that looked clean in round one.
Approving.
One non-blocking note: an indeterminate verdict leaves signedOutStreak where it is rather than resetting it, so the two strikes can span an indeterminate probe in between. Defensible — "change nothing" is what indeterminate is supposed to mean — just flagging it as a deliberate-looking choice nobody wrote down.
…#15) * ci: make cargo test blocking on every OS by fixing the Unix-path tests The Windows and Linux test legs ran under continue-on-error because 14 tests failed on Windows, all from Unix-path assumptions that had never run off-macOS. A warn-mode leg reports green while failing, so nothing Windows-only - including code that exists ONLY under `#[cfg(target_os = "windows")]`, which no other leg compiles - had a gate that could go red. That enforcement gap surfaced in review three times; this closes it structurally. The 14 break down into four kinds, each fixed at the level it deserved: - clipboard (5): `file_uri_to_path` delegates to `url::to_file_path`, whose accepted shapes are platform-specific by design - a driveless /home/... URI is an error on Windows. Per-platform fixtures; identical assertions on both. - files (2): scan labels were rendered with `display()`, yielding `plans\feat.md` on Windows. Labels are UI-facing logical names (the `path` field carries the OS path), so this one is a production fix: a `slash_label` helper renders them `/`-separated everywhere, and its second call site fixes the same latent bug in the AGENTS.md scan. - history (1): `normalize_path` deliberately lowercases on Windows (NTFS is case-insensitive); the test asserted the Unix rendering. Assert the platform-correct value on each. - community_skills (2) + codex skills (4): fixtures hand-formatted TOML with raw Windows paths - `\U` parses as a unicode escape, the config fails to parse, and the rules silently never applied. Production is immune (it writes through toml_edit, which escapes); the fixtures now use TOML literal strings and native-separator joins, since the rule match is exact string equality against the scanner's path. The community_skills pair also compared rendered strings where PathBuf equality asserts the same slug layout without the separator. With the suite green everywhere (740/740 on this Windows host), the warn-mode step is deleted and `cargo test` blocks on all three OSes. The trigger runs CI on this PR itself, so the ratchet self-validates before it can gate anyone else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: file the watcher doc on its module; write down the streak choice Two non-blocking notes from the #13/#14 reviews. The 11-line "why this watcher exists" block ended up attached to mic_watch_policy when that module was inserted in front of it, leaving default_mic_watch - the module it describes - undocumented; moved back down. The policy module's own doc justified its placement by the Windows leg being warn-mode and red, which the previous commit makes untrue; it now states the durable reason (compiled on every leg, not one). And probeAuth now says out loud that an indeterminate verdict deliberately leaves signedOutStreak untouched - it is not evidence of being signed in - so two strikes may span an indeterminate probe between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#1 docs/CHROMIUM.md described the architecture this PR deliberately did not ship. The MCP section, the tool table and the whole Layout table named files that do not exist. Rewritten against the code: the three IPC commands that replaced the server, the real file map, and why each command takes a `Window`. The crash forensics and the pump section are kept — they were the parts worth having. #2 The authorization ledger guarded nothing. `attach`/`is_attached` shipped with tests and a doc calling itself the gate agent tools must consult, while nothing called any of it — worse than no gate, because it reads as a control in review and answers "allowed" at runtime. Deleted. What actually keeps the CDP commands app-only is now real: each takes a `Window`, which the op table classifies as opaque and REFUSES over the remote WS transport. That was the live hole — arbitrary JavaScript in a signed-in Google session, reachable from a socket. #3 cef-fetch fell back to an unpinned CEF on a warning nobody reads inside a gigabyte of clone output, producing exactly the framework/bindings mismatch its own header calls undefined behaviour. Hard-fails now, naming the tag and the crate version it must match. #4 CI compiled none of it. `cargo test --workspace` (the engine suites, including report.rs's — kept outside the feature gate precisely so CI would run them, which it then didn't) and `cargo check -p brains-browser --features chromium`, which is what keeps ~2,000 gated lines from rotting. #5 `page_target_for_url` could resolve to the wrong tab — two Gmail profiles are two page targets on one origin, and `find` took whichever came first, putting one account's mail into a conversation about the other. Targets are now pinned per tab on first resolution and looked up by id after; resolution skips ids another tab already owns. #6 One OS thread per delayed pump request, spawned continuously during load and input. Replaced with the single timer the safety pump already owned, waiting on a condvar with a next-deadline slot. #7 `set_visible(true)` never restored the container's frame, so a view whose panel had not re-measured stayed parked off-screen while nominally visible. The frame is remembered on hide and restored on show — the OS-webview backend has no such asymmetry, and the panel is written not to have to know. #8 `views::open` registered the tab after dispatching the closure whose failure path removes it. Inserted first. #9 Same-site now also requires a USER GESTURE before navigating in place: the panel has no address bar, and a script-initiated hop needs none. `TWO_LABEL_SUFFIXES` gains the user-content hosts, which matter more than the country ones — anyone can take a label under `github.io`, and treating two of them as one site is the actual risk. Test added. #10/#11 The unauthenticated CDP port and the `--use-mock-keychain` + `no_sandbox` pair are now named on a release-blocker list in the doc, with what has to change. #12 The dev-token file was created at the umask and chmod'd afterwards, leaving it world-readable in between. Opened 0600, directory 0700. #15 One `reqwest::Client` for the process rather than one per call; the sentinel parse drops a fragment; "flipped bottom-left" corrected (in AppKit flipped means TOP-left) — the arithmetic was right, the word inverted it; and `init` latches on ATTEMPTED so a failed attempt cannot re-enter CefInitialize. Not done, deliberately: #13 (splitting the CI commit) is the owner's call and the offer stands; #14's unused CDP driving surface is named in the doc's Next section rather than trimmed, since the agent bridge is what lights it up. Gates: fmt 0, clippy 0, workspace tests green, chromium check clean, svelte-check 725/0/0, 1659 frontend tests, all five lints. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An expired vendor session was a dead end: the typed message was spent, the CLI's auth failure rendered as the assistant's reply, and the session got permanently named after the error. Five commits turn that into a recoverable state that announces itself before anything is lost.
Found and verified on a real Windows 11 install against a genuinely signed-out CLI. Cross-platform - every file here is platform-agnostic TypeScript with no
platform/win32/darwinbranching; the same failure occurs on macOS.Baseline (what a user actually got)
Sending with an expired session produced, twice in a row:
rendered as an assistant message bubble — plus:
"Failed to authenticate: OAuth session expired and could not"— permanentlyThe app already had a
handleTurnError→ "Session expired → Reconnect" card wired to three failure paths. It never fired, because this failure arrives as assistant content, not as an error event.1. A transient auth blip no longer brands a session forever
A session is titled by a throwaway CLI run whose only job is to name it. When that run fails on expired auth it returns the failure text, and
sanitizeGeneratedTitleaccepted it — the string clears every filter:"Failed to authenticate: OAuth session expired and could not"Character-for-character the observed title. Now rejected, keeping the prompt-derived name.
The check uses a new strict matcher, not the existing
isAuthExpiryError: that one deliberately matches bare"authenticate"/"unauthorized"so raw errors are caught generously, but a title is ordinary content where a false positive silently discards a good name."Fixing the unauthorized API error"still titles correctly.2. The expired session announces itself before it eats a message
A background probe (startup, focus, visibility, 2-minute idle tick) raises the existing card as soon as the CLI reports signed out, and a send while it is up is refused rather than spent, with the text handed back to the composer.
Deliberately not a pre-send check:
claude auth statusspawns a process and measured ~700ms on this machine, so gating every send would tax every message to catch a rare state. Probing in the background keeps sends instant and — better — shows the user they are signed out before they write a long prompt.The guard runs before any turn state is mutated. Placed later it would strand a user bubble in the transcript with no reply, since the message is appended a few lines further down.
Fail-soft throughout: only a definite signed-out answer raises the card. A probe that throws (CLI missing, timeout) is ignored, so a user who could actually send is never blocked.
3–5. Reconnect reports its outcome (three rounds of real-app testing)
Each of these was invisible to code review and only surfaced by driving the flow:
3. Silent success. Clicking Reconnect tore the card down immediately, so a completed login and one that silently failed looked identical. Now the button becomes a disabled "Waiting for sign-in…" and the outcome is reported: a green "Reconnected to Claude — your message is back in the composer", or the expired card returns. Also fixed a stale-card bug — clearing was gated on there being no stashed prompt, which is exactly the state after a refused send, so a "session expired" claim could survive a successful reconnect.
4. No focus event. The login flow completes in-process (
run_claude_login→ its owncheck_auth_status) and fires no window focus, so the observer never ran and the idle tick was two minutes out. Added a short backing-off poll after the click.5. The remount. Signing in can swap the view and remount the component, killing the timers from the click and resetting the in-memory flag — leaving the persisted intent with nothing watching it. That is why testing showed the confirmation "only after switching tabs": an unrelated focus event was the only thing left to trigger a probe. The intent now lives in
sessionStorage(timestamped, 10-minute expiry so an abandoned login can't congratulate a later launch) and is picked back up on mount, which restarts polling.Verification
claudeAiOauthtokens — the same shape observed in the wild, whereaccessToken/refreshTokenwere empty strings andexpiresAtwas epoch 0 whilerefreshTokenExpiresAtwas still a week out.claude auth statusreportsloggedIn:falsefor that state, which is what the probe keys off.npm test: 1704 passing (67 files), including new title-guard tests covering the exact branding string and the "merely mentions auth" cases.eslint: 0 errors.svelte-check: unchanged.Notes for review
--no-verify. The pre-commit hook runs a project-widesvelte-check, which fails on a pre-existingvite.config.tserror (Cannot find name 'process') unrelated to this change — it blocks every commit touching.ts/.svelterepo-wide. A one-line@types/nodedevDependency fixes it and would also let the svelte-check CI gate ratchet from warn to blocking; happy to send that separately rather than smuggle it in here."Failed to authenticate: OAuth session expired and could not"stays on any session named before this lands and has to be renamed by hand.SetupWizardand typechecks, but nobody has driven it on a ChatGPT-only install. Worth a confirmation from someone with that setup before merge.🤖 Generated with Claude Code