Conversation
Claude Code 2.1.274 follows both credential files when they change
underneath it: it polls ~/.claude.json with fs.watchFile at 1 s and
re-stats .credentials.json at the top of every token-refresh check,
dropping its cached credentials when the file moved. Verified by handing
a running `claude -p` a second account's credentials four seconds in; it
re-read them and died on that account's 5h limit four seconds later.
So `switch` and `monitor` no longer refuse while `claude` is running, and
`monitor` is no longer OpenCode-scoped. `save` still refuses, because it
pairs tokens from .credentials.json with an identity from ~/.claude.json
and a /login updates those separately, which mislabels a slot with no
later pass to correct it. `warmup` and `monitor -KeepWarm` still refuse
because they make every slot active in turn.
Reconcile had to be made safe first. It decided whether .credentials.json
had changed account by reading an email out of ~/.claude.json, a
different file, so anything that moved the tokens without moving that
email was taken for a refresh and mirrored over the tracked slot. A
/login does exactly that between its two writes, and `usage` and `list`
reconcile without refusing, so the hole was reachable before this change
rather than because of it. Two guards now sit ahead of the mirror, the
only destructive outcome:
* Bytes byte-identical to another saved slot are that slot, whatever
the email says, so it is adopted rather than copied over the tracked
one. Adopting carries the identity into ~/.claude.json too, since it
is the one outcome that changes which account is active; left stale,
the next reconcile auto-saved a duplicate of an account already
saved.
* Bytes that cannot be attributed to any account are not written at
all. The previous behaviour preserved continuity over caution and
overwrote the one artifact a login cannot be recovered from.
The concurrency rationale is consolidated onto Test-ClaudeRunning, which
AGENTS.md already names as its owner, and a contradicting copy of it at
the foot of the same docblock is removed.
Follow-up to the hot-swap commit. Dropping the running-guard from switch and monitor was right, but it left the credential-safety story with holes that were only reachable, or only consequential, once those actions ran beside a live client. Reconcile only closed the /login window for accounts that were already saved. A /login to an unsaved account produces bytes matching no slot, while ~/.claude.json still carries the previous account's email, so the email probe read "same account, just refreshed" and the mirror overwrote the one artifact a login cannot be recovered from. The incoming tokens are now asked directly, via /api/oauth/profile, before that write. Only while a client is running, because that is the only time the window can be open, and only a proven mismatch blocks the write: treating "could not ask" as "different account" would freeze slot files behind an unreachable endpoint, and a slot that stops tracking refreshes is dead after two of them. The probe does not refresh, because it runs against tokens a live client may be mid-request on. It compares account uuids, not emails. claude.exe 2.1.276 assigns oauthAccount.accountUuid straight from this endpoint's account.uuid, so the two are the same field; the email is not equally safe, because one login path fills emailAddress from the access token's own embedded address instead. The response schema and that assignment are now recorded in the unofficial-constants block with the extraction recipe, which is where a claim like this has to live to survive an upgrade. The adopt check also sat inside the tracked-slot branch, so it never ran when there was no tracked slot to compare against. Read-ScaState's hash bootstrap covers a MISSING state file but not a corrupt one, which takes the parse catch and returns null, and reconcile then auto-saved a second copy of an account already saved and moved active tracking onto the copy. Hoisted above that branch, where byte equality answers both cases. Rotation swapped without re-capturing. The poll reconciles before reading usage, then spends a full serial HTTP pass across every slot, and the swap at the end discarded anything Claude Code wrote in between, leaving the outgoing slot holding a refresh token the server had already rotated. Unreachable before, because rotation refused beside a live client. The rest is comments that still described the old contract: four sites claiming the ~/.claude.json write is gated on Claude Code being closed, Invoke-SlotSwap contradicting its own docblock fourteen lines down, and a residual-risk note scoped to the config file that concluded "never a credential" while the credentials-side race above was open. The usage row in AGENTS.md no longer says "Read-only" either, since reconcile's adopt branch writes ~/.claude.json from usage and list.
Test-CredentialAccountMatch compares sidecar.oauthAccount.accountUuid and
nothing else, but two of the three places that build an oauthAccount block
hardcoded that field to $null. Both are the /api/oauth/profile fallback,
written before the endpoint's uuid was extracted. A slot born on either
path gets a sidecar Read-Sidecar accepts, because that only requires an
emailAddress, and the probe then answers 'sidecar-has-no-uuid' for it
forever: the mirror-overwrite guard is off for that slot and nothing says
so.
The fix is a single constructor rather than a third correct copy, since
the defect is that the copies drifted from Get-SlotProfile's contract when
it grew a field. New-OAuthAccountFromProfile is the one place that knows
how a profile result becomes a sidecar, and the mismatch branch uses it
too: Test-CredentialAccountMatch carries Email and AccountUuid through
from the same endpoint unchanged.
Reconcile's source label had to stop being inferred in the same breath. It
read `if ($newAccount.accountUuid) { 'claude_json' }`, which was only ever
right because the fallback left the uuid empty; populating it would have
relabelled every fallback sidecar as claude_json. Set per branch now, the
way Invoke-SaveAction already did.
The "do not write what you cannot attribute" rule was implemented inside the tracked-slot branch, on the reading that the mirror is the only destructive outcome because it overwrites a slot file. The auto-save fallback is not the harmless alternative it looks like. Reached with no identity, it calls New-AutoSaveSlot with a null account, which skips the sidecar; Get-Slots hides a sidecar-less slot and `sca remove` cannot reach it by name, so the tokens land somewhere the user cannot act on. It then points state.active_slot at that invisible slot, and whatever was tracked before silently stops receiving refreshes. So the check moves up beside the adopt check, ahead of all three writers, which is where the docblock's outcome list already put it. Reachable whenever ~/.claude.json carries no email and the profile endpoint does not answer: offline on a fresh install, or with a corrupt state file, which Read-ScaState's parse catch returns null from without running the hash bootstrap. The advisory gains a second form. With no tracked slot there is nothing to name and nothing to re-save, so it says what did not happen and asks for a name instead of offering one. The -not $slotEmail half of the old condition is dropped rather than hoisted. It was unreachable: Read-Sidecar rejects a sidecar without an emailAddress, so every slot Get-Slots returns has one. That guarantee is now recorded where $slotEmail is read, because it is also what stops the equality test below from matching empty against empty. Three auto-save tests were resting on the old behaviour. One asserted it directly, describing the invisible slot in its own comment, and is replaced by its opposite. The other two are about a missing slot file and about the advisory text; both now resolve an identity, which is what they meant to assume all along.
Invoke-Reconcile reads .credentials.json once at the top and writes those same bytes, which is the contract its docblock states: if Claude Code rewrites the file mid-reconcile, the slot stays consistent with our hash and the next pass catches up. The identity probe broke that contract without saying so. Test-CredentialAccountMatch takes a path, so Get-SlotOAuth re-reads the file, and the verdict describes whatever is on disk at that moment rather than $bytes. The gap between the two reads is not instantaneous: a ~/.claude.json parse, a hash pass over every saved slot, a Get-Process, and an HTTP round trip. And the event that fills it is the one this whole branch exists to catch, a /login writing its two files in sequence. On the mirror arm that costs nothing, because it writes bytes we read into a slot the verdict already attributed. On the mismatch arm it inverts the fix: reconcile auto-saves $bytes, the OLD account's tokens, under the email and uuid the probe read from the NEW one. That is a slot whose sidecar names the wrong account, which is exactly what `sca save` refuses to run beside a live client to avoid, and unlike a bad mirror nothing later corrects it. So the mismatch arm re-hashes the file before it writes and declines when it moved, leaving the tracked slot alone and the sync hash unadvanced so the next run re-reads and re-decides. A hash of one small file is cheap next to the request that just returned. The mirror arm keeps the existing read-once behaviour, and the docblock now says which arm pays and why.
The pre-swap Invoke-Reconcile was added to capture a refresh landing between the poll's own reconcile and the swap, and its result was piped to Out-Null as though capturing were all it does. Three of its outcomes move state.active_slot: adopt, identity-change, and auto-save. When one of them fires, the decision in hand is stale. It was computed by Get-AutoRotationDecision from a snapshot taken before the reconcile, judging the slot that was active then. Rotating on it moves off an account whose usage nobody has read, possibly a fresh one with a full window, and latches a 'Rotated from "X"' line naming a slot that was not where we came from. Reachable whenever something changes the active account during a poll: a /login in another terminal, a hand-run `sca switch`, or a slot moved into place by a second sca. All of which became ordinary once switch and monitor stopped refusing beside a live client. Skipping is the whole fix. The cost is one poll interval, and the next poll reads usage against the account that is actually active and decides again; the latch this returns is replaced by that decision, so it cannot stick. The alternative, re-running Get-AutoRotationDecision here, would need a fresh usage snapshot to be worth anything, which is a serial HTTP pass across every slot inside the rotation step.
Comment-only, plus one user-visible string and its README twin. Invoke-WarmupAction's docblock still gave the ~/.claude.json-cache reason for its refusal, contradicting the throw eight lines below it, which was rewritten to the real one: warmup makes every slot active in turn. Invoke-KeepWarmStep cited "same rationale as Invoke-AutoRotationStep" for its per-tick Test-ClaudeRunning. That check no longer exists in Invoke-AutoRotationStep, and the rationale is now the opposite of shared: rotation moves to one destination and needs no check, keep-warm walks the fleet and does. `usage -Watch` is not read-only. Its per-poll reconcile mirrors into the tracked slot, and the adopt branch writes both the state file and ~/.claude.json. AGENTS.md's usage row was corrected when that branch landed; the script's own comment, the flag-misuse guard's error message, and the README sentence were not. "Does not rotate" is the accurate contrast with `monitor`, and the one the reader actually needs.
Reconcile's identity fallback called /api/oauth/profile without -NoRefresh,
the switch added for exactly this hazard and applied only to the sibling
probe. On an expired access token it rewrote .credentials.json through
Update-SlotTokens, rotating the refresh token out from under a client this
branch now lets run, and stranding the (bytes, hash) pair every branch below
decides on: the mirror then filed the pre-refresh, already-dead bytes into
the slot. Invoke-SaveAction keeps the refresh, being the one caller that
still refuses while Claude Code is running.
The four actions that overwrite .credentials.json after reconciling read the
outcome now instead of discarding it. Only auto-rotation checked anything,
and it allowlisted three Action values, so the two noop reasons that write
nothing, identity-unresolved and credentials-changed-mid-probe, fell straight
through to the swap and destroyed the refresh the call was added to preserve.
Reconcile answers the question directly with `Captured`: switch, warmup and
monitor -KeepWarm refuse, auto-rotation abandons the tick.
A bare `Mock Invoke-Reconcile { }` returns $null, which now reads as
Captured = $false, so the stubs meaning "not what this test is about" go
through a fixture that says so.
… seam The hot-swap work left the same three facts restated at a dozen sites. The /login window that makes ~/.claude.json's email untrustworthy was written out in full ten times, the reason warmup and keep-warm refuse a live client six times, and eight comments described code that is not there rather than code that is. A rule stated in ten places is a rule nothing enforces, which is how two of them ended up contradicting the code they sat next to. Test-ClaudeRunning was already the place every refusal points at, so it takes the window too and the rest point at it. The uuid-versus-email evidence stays on $Script:ProfileEndpoint, which is the only place that can source it. Invoke-Reconcile's tracked-slot arm becomes Confirm-TrackedSlotIdentity: four ways of answering one question, two of them a network round trip, returning same / differs / moved. The caller is a flat dispatch over the three, and the mid-function rebinding of $newEmail, $newAccount and $sourceLabel is a return value instead. LOC 238 to 182, CC 21 to 17, nesting 4 to 3; the new function is CC 7 at nesting 1. No behaviour change: the suite is untouched and passes.
…ally Two records of one account may legitimately disagree about the email: Claude Code fills ~/.claude.json's emailAddress from the profile response on one login path and from the access token's own embedded account_email on another. Reconcile compared only that field, so one account could read as two and auto-save a second slot for a login already saved, labelled with whichever email form it had not seen. Test-SameOAuthAccount compares accountUuid where both sides carry one and falls back to the email only where one does not, which Read-Sidecar still permits. The converse now also holds offline: two accounts sharing an email were separated only by the network probe, and only while a client was running. The adopt branch committed state before writing ~/.claude.json and swallowed the failure, leaving state naming the twin while the email named the old account. Nothing revisited that: the next reconcile hash-matched and returned, and the one after took the differs arm and filed the twin's tokens under the old account's email and uuid, the permanently mislabelled slot `sca save` refuses to create. The identity write goes first now and the state write is conditional on it, so a failure leaves both files alone and the next run retries. Set-OAuthAccountInClaudeJson also throws when there is no identity on file at all, and that case still adopts: nothing can go stale against it. The fixtures gave one account two uuids depending on which helper wrote it, which is a state that cannot exist on disk and which the email-only comparison hid. Both helpers key off the email through Get-TestAccountUuid now.
The changelog gains an entry per fix and folds the offline uuid comparison into the identity-check entry it belongs to, since the two now decide the same question by the same field. The README's hot-swap section says the credential guarantee rests on uuids rather than emails, and shows the refusal, which is the one new message a user can hit from a command that used to always proceed.
Set-SlotRateLimitBackoff stamped RateLimitedUntil only on slots that already held a cached reading, which excluded every slot throttled from its first poll: exactly the ones showing em-dashes, and exactly the case the commit that introduced it (e25122c, 'recover automatically from a sustained rate-limit stall') was written for. Each poll then re-ran Update-SlotTokens' three-attempt ladder against an endpoint already answering 429. Measured on a live account: 360 requests an hour for two such slots at the 60 s default, traffic that plausibly sustains the throttle it is probing. A live probe confirmed the 429 is genuine, shape-independent and specific to /v1/oauth/token; /api/oauth/usage answered 200 in the same minute. The entry is now created when absent, which puts a Data = \ record in a map every reader treated as holding a reading. Both readers are guarded at the source rather than left to infer it. Get-CachedUsageOrNull refuses such an entry: serving it would have returned Status = ok with no numbers, which Get-RowMaxUtilization scores 0% and auto-rotation then reads as a healthy idle slot, promoting a slot precisely because nothing could be read from it. Get-SlotUsage drops -CachedReason when it has no numbers, since that flag is what routes a row to the 'showing last known usage' advisory, which would have described an em-dash row wrongly. The keep-warm cooldown was flat, which assumes the next attempt can differ. While the token endpoint is throttled it cannot: claude -p refreshes through that same endpoint. A throttled row with no data also scores 0%, so Test-WarmEligible's at-limit gate could not hold it off either, and the pair left such a slot retried every five minutes for the life of the watch at roughly \.004 a time, about \.15 per slot per day. The cooldown now doubles per consecutive failed warm to a ceiling and resets on the first success, so the fast first retry the transient case needs is kept and a persistent failure goes quiet. \ is optional, so existing one-shot callers keep the flat behaviour. Separately, the refresh now sends the scope field the client has always sent, and the pinned User-Agent moves to 2.1.278, the build the constants were re-verified against; TOKEN_URL, the client id and the beta flag are unchanged there. Neither divergence was breaking anything, and a probe replicating the client's exact body and headers still drew a 429, so this is hygiene rather than the fix. All three predate this branch: both defects entered in e25122c on 2026-06-18 and shipped in v3.0.0, v3.0.1 and v4.0.0. No commit on develop touches the functions involved.
Confirm-TrackedSlotIdentity asked /api/oauth/profile whose tokens were in .credentials.json only when Test-ClaudeRunning reported a live client. That probe is documented as blind to an npm-installed Claude Code on Windows and macOS, so on those hosts the guard never fired and the mirror overwrote the tracked slot with another account's tokens: the loss the guard was added to prevent, on two of three platforms for a common install shape. It now probes whenever the bytes moved. The gate was buying less than it looked like, too. Invoke-Reconcile returns at its hash check unless .credentials.json actually changed, so the round trip costs about one call per token refresh rather than one per command, and it was being traded against a process enumeration on every reconcile. The same function took $Slot and $Hash by parameter and then reached for the script-scope $CredFile for the two operations that matter, so it could not be exercised against anything but the live file. $CredentialPath is injected now. Invoke-KeepWarmStep swapped every eligible slot without re-reconciling. Invoke-AutoRotationStep gained that guard; this site did not, although it runs later in the same poll, behind the same reconcile and the same full serial usage pass, and swaps more slots than rotation does. The startup guard in Invoke-UsageWatch covered tick 0 only, so from the first poll onward a refresh landing in that window was discarded and never mirrored. It refuses the tick now, with a latch rather than a throw, matching rotation. Get-UncapturedCredentialsRefusal described both of its causes as an unresolved identity. On credentials-changed-mid-probe the account was resolved and the file moved underneath the probe, so "could not be attributed to an account" named neither the cause nor the fix, which is to re-run. The 'sca save' it recommended was a dead end exactly where it was most likely to be read: save resolves identity from the same two sources that just failed, and refuses outright while Claude Code is open. It branches on the reason now and carries that precondition. The uuid-versus-email evidence was stated in full at three sites, two of which also pointed at the third, and the uuid case-insensitivity note at two. Both live once now, at $Script:ProfileEndpoint. Four stale test contracts go with them: two file headers still claiming switch and monitor refuse a running Claude Code, and two comments describing surfaces that no longer exist.
Update-SlotTokens ran its full three-attempt ladder for every slot it touched, so a sweep across two throttled slots spent six POSTs and about twelve seconds. Four of those requests and six of those seconds went to re-establishing a fact the first slot had already established. The ladder was written for a per-token limiter documented as unlocking within seconds. The 429s measured on 2026-09-19 never reach it. They are served at Cloudflare's edge (Server: cloudflare, CF-RAY) and carry no Retry-After, no RateLimit-*, and no reset or quota header of any kind, so there is nothing to negotiate with. A deliberately invalid refresh token and a deliberately invalid authorization code both drew 429 rather than invalid_grant, which places the limiter ahead of grant validation and keys it to the origin rather than to the token, the account, or the grant type. Attempts two and three cannot clear that, and each one is another tally against the address that tripped it. Test-TokenEndpointThrottled reads the existing per-slot RateLimitedUntil stamps rather than introducing a parallel flag, so the question "are we throttled" keeps one record and cannot drift from what Set-SlotRateLimitBackoff wrote. The first 429 of a run still pays full price, because nothing is known before it. The collapse applies to the active slot too. Where the throttle is origin-level, as measured, the two extra attempts were useless for it as well. Where it is genuinely per-token, that slot fails after one attempt and recovers on the next run rather than the current one. That cost is accepted rather than worked around: exempting the active slot would put a state-file read inside a refresh primitive. The retry guard inside the catch had to move to the same bound. Left reading TokenRefreshRetryMax, a collapsed single attempt would fall out of the loop without rethrowing, and the 429 would surface as a bogus "missing access_token".
Format-UsageTable was 199 lines at CC 34, the second most complex function in the script. It built cells, measured columns, rendered a header and painted rows in one scope, so every rule it owns could only be reached by rendering a whole table and matching a regex over stdout. Four units come out, and each one answers a question the table was the only way to ask: Get-UsageStatusLabel is the Status column's vocabulary, which is really the tool's vocabulary: eight arms covering plan usability, four HTTP states and the two warmup transients. It was a switch inside a foreach inside a renderer. Asking what a rate-limited row with cached data renders as now costs one call. ConvertTo-UsageTableRow maps one row to its cells and is a function of that row alone, which is what makes the em-dash rules and the active marker testable without a table around them. Measure-UsageTableColumns owns the widths and the total line width. That total also sizes the aggregate bars, so it is a shared quantity rather than a local, and a test now pins it to the format string it has to match. Write-UsageTableHeader owns the -Auto indicator, including the width maths and the silent drop on a narrow terminal. It is the only part of the table that reads the terminal, so isolating it puts the one environment-dependent branch behind a seam. Format-UsageTable is 47 lines at CC 7 and now reads as the four steps in order. No output changed: the existing rendering tests pass unmodified, which is the whole verification argument for this commit. 21 tests are added against the new units, and file coverage rises from 90.9% to 91.7%. Invoke-Main was the other candidate and is deliberately untouched. Its CC is about ten from the action switch, which is the CLI surface and does not reduce, and the rest is a sequence of phase gates whose order is load-bearing: Assert-CredentialDir runs before the credentials directory exists so a refused run leaves no trace on disk, and New-CredentialDirectory runs after the flag-misuse guards for the same reason. Grouping the three 'not profileOnly' branches, which is what the shape suggests from outside, would let 'sca monitor -Json' create the directory before it throws. No changelog entry: nothing a user can observe changed.
Invoke-UsageWatch is 346 lines at CC 35 with nesting 5, and 112 of its commands are uncovered, which is about half the coverage gap in the whole script. It is first on every one of those axes. The plan names the two causes: one try block whose four terminal-state locals are captured 260 lines above the finally that consumes them, and seven mutable locals that nothing can be lifted away from. Writing it down rather than doing it, because the work has a constraint worth agreeing on before any code moves. Six tests in Helpers.Tests.ps1 locate this function by name and assert over its AST, and two of them are negative assertions: no Write-Host carrying a VT escape, and no ESC[2J. Both are trivially true of an empty set, so moving the VT writes into an extracted function leaves the flicker fix and the clear-screen guard unenforced while the suite stays green. Retargeting all six onto a family list is phase zero and blocks everything after it. The second constraint is that the suite cannot prove this refactor at all: the loop needs a TTY, which is why those guards are static in the first place. Every phase therefore ends with a named manual smoke test rather than a green run alone. Six phases, one commit each, front-loaded: phases zero and one carry most of the structural benefit and stopping there leaves nothing half-done. The file says at the top that it is a work plan and is to be deleted when the last phase lands. It documents work that has not happened, so it is not a second copy of anything the code owns, which is the failure mode that took .claude/rules/script-internals.md out. Two review passes before writing changed three things: the entry-guard extraction is dropped (those three guards are the function's contract and belong at its entry), the frame-paint sequence turns out to be duplicated between the startup repaint closure and the loop so extracting it is a deduplication rather than a move, and the footer has two shapes, since the startup pass emits only the latches while the loop also appends the poll timestamp and failure tail.
sca's request to /v1/oauth/token can be refused before the server looks at the grant. Measured 2026-09-19: a deliberately invalid refresh token, a deliberately invalid authorization code, and a known-good grant all drew the same 429, served at Cloudflare's edge with no Retry-After and no rate-limit header of any kind. From that vantage a revoked login and a throttled one are indistinguishable, so 'rate-limited' was not a reading but a guess, and it asserted the one thing that was false: that the condition clears on its own. It cost a real diagnosis. Two slots sat labelled 'rate-limited' for hours while their grants were in fact revoked, and the advisory told the user to wait. claude -p had already returned the right answer during a warm pass; the next poll overwrote it with the blind label, so the tool discarded its best evidence in favour of its worst. Invoke-WarmAllSlots now records what the activator concluded when the grant itself was refused, and Get-SlotUsage prefers that recorded verdict at the two points where it would otherwise report a 429 it cannot substantiate. claude reaches the grant where sca is turned away, so where it has ruled, that ruling wins. The verdict is keyed on a hash of the credential file rather than a timestamp. A verdict is about specific bytes: re-logging in and running sca save rewrites the file, the hash stops matching, and the record retires itself. Nothing has to remember to clear it, which is the one failure mode a stale-by-age scheme cannot avoid. A successful activation clears it outright, since authenticating as the slot is the proof that retires it. Persisted in the existing .sca-state.json as an optional auth_verdicts block, omitted entirely when empty so a healthy pool's state file keeps the bytes it has always had. Schema stays 1: the reader already tolerates fields it does not know, the same tolerance that carries the legacy last_warmup_at. sca usage alone still cannot discover a dead grant, having no way to ask. So the advisory stops guessing on its behalf: a throttled row carrying numbers was read at some point and reads as before, while one with nothing to show says so and names sca warmup <slot> as the way to tell the two apart. The expired remedy names re-login as the escalation as well, rather than only sca switch, which cannot refresh a grant the server has rejected. AdvisoryMaxLines goes from 8 to 9 to keep the invariant its comment states: conditions plus remedies always fit, so every failing slot is named whatever else is dropped. There are five condition lines now.
The two blocks that record what Anthropic ships, rather than why this tool is written the way it is, move to docs/claude-code-internals.md: the extraction recipe and binary provenance, the client-id disambiguation, the full /api/oauth/usage and /api/oauth/profile response schemas with the Zod shape, the evidence behind comparing account.uuid instead of account.email, and everything about Claude Code's credential backends. That file states its admission rule at the top, and the rule is what keeps it from becoming .claude/rules/script-internals.md a second time. A fact belongs there only if it is about an external artifact, carries the version it was observed against, and has a recipe that makes staleness detectable by running something rather than by noticing. The credential-backend markers already have a stronger check than that: tests.yml re-scans the darwin build for them on workflow_dispatch and fails if the plaintext backend disappears. Nothing is copied. The doc holds evidence, the code holds the rules that follow from it, and those are different sentences. The identity rule stays where it is applied, compressed to the part a reader of Test-CredentialAccountMatch actually needs: compare accountUuid, never email, case-insensitively. Both call sites now point at the doc directly instead of hopping through the $Script:ProfileEndpoint docblock. Measured constants stay put. The HTTP budgets with their 46-2108 ms observation, the token retry policy, and anthropic-version are rationale for a number, not archaeology, and a number separated from what measured it is the next one someone changes blind. Counts that tally code constructs are also gone, because they go stale silently the moment the construct count changes. This is not hypothetical: an earlier commit in this branch had to correct "four ways of answering a single question, two of them costing a network round trip" to "three ways, one of them" for no reason other than a branch being removed. The enumerations that survive are the ones inherent to a concept, like the two files a /login updates separately. One stale comment turned up while editing: the reconcile docblock still described the mirror's identity probe as running "only while a client is running", which stopped being true when that gate was removed earlier in this branch. AGENTS.md gains the pointer and the admission rule in one paragraph, and is 18,484 bytes, a byte under where it started and sixteen under the ceiling.
Six static tests located the watch code by naming Invoke-UsageWatch. Two of them are negative assertions -- no Write-Host VT escape, no ESC[2J literal -- and a negative assertion is trivially true of a function that no longer holds the code, so moving that code into a helper would have disarmed both while the suite stayed green. Route all six through one Get-WatchFamilyAst helper that owns the list of functions making up the watch engine, throws when a listed name is absent from the script, and returns every match. Pair each negative assertion with a positive existence check over the same family: at least one Write-VTSequence call, at least one ConvertTo-WatchFrameSequence call. Loosen the last-poll stamp guard from an exact '$lastPoll' match to a suffix match so it survives the local becoming a field on a session object.
…tform
[Console]::CursorVisible's getter carries [SupportedOSPlatform("windows")]
and throws PlatformNotSupportedException on Linux and macOS; only the
setter is portable. The watch engine read it unguarded as its first act
after the entry guards, so `sca usage -Watch` and `sca monitor` aborted at
startup on two of the three supported platforms. No test caught it: the
IsOutputRedirected guard refuses first under CI, so nothing ever reached
the line.
Guard the read and treat a failed capture as "no API restore", because
writing that $null back through the setter would coerce to $false and
leave the user's cursor hidden. The ESC[?25h in the alt-buffer leave is
what the cursor actually depends on and still goes out.
Extracting the lifecycle is what makes the fix expressible. Four locals
captured at the top were consumed by a finally 260 lines below, which
trapped the whole loop body in that scope; Enter-WatchTerminal now returns
them as one token and Exit-WatchTerminal consumes it, so the try/finally
is three lines. Ordering the alt-buffer entry last inside Enter- keeps the
one mutation that needs undoing unreachable by a throw the caller's
finally could not catch.
Exit-WatchTerminal is unit-tested through a [Console]::SetOut swap: write
order, the empty-title payload, control-byte stripping, the no-op when the
alt buffer was never entered, and a null token.
Seven locals -- snapshot, last poll, last poll error, two footer latches and the two warmup maps -- were read and written across the whole watch loop. Nothing could be lifted out of it while its state was loose variables, because every candidate extraction needed four or five of them. New-WatchSession returns them as one object, mutated in place by its consumers. That follows Invoke-KeepWarmStep, which already mutates the caller's WarmupTimes / WarmupFailures hashtables, so a mutable bag is the existing convention rather than a new one. No behaviour change: same initial values, same mutation points, same order. The -Warmup repaint closure now reads the latches off the session, which it resolves through its defining scope exactly as it resolved the locals before.
The poll step was an 80-line block inside a `while ($true)` that no test could enter, which is why its guarantees were pinned statically: an AST assertion that the last-poll stamp is not taken from the pre-poll timestamp, and another that both credential-touching calls carry 6>$null. Both were proxies for behaviour nothing could exercise. Invoke-WatchPoll takes the session and the mode switches and mutates the session in place. With every collaborator mockable, ten tests now drive the step's own decisions: that it clears a stale error, that a throw parks the message and keeps the previous snapshot on screen, that the title does not move on a failed poll, that the stamp lands after the work (measured, not inferred from the parse tree) and lands even when the poll threw, that rotation and keep-warm each fire only under their switch, that keep-warm runs after rotation, and that the reconcile precedes the usage read. No behaviour change; the order and the suppressions are as they were.
The Get-WatchFrameText -> ConvertTo-WatchFrameSequence -> DEC 2026 envelope sequence was written twice, once in the -Warmup startup repaint and once in the polling loop. Two copies of a paint whose whole correctness argument is "one write, and never an ESC[2J" is one copy too many: the static guard that enforces the second half could only ever scan text, so a divergence between the two sites would have had to be caught by eye. Write-WatchFrame takes the render block and owns both halves. Both callers collapse onto it, and the flicker rationale that was duplicated across the Invoke-UsageWatch docblock and ConvertTo-WatchFrameSequence now lives with the code it describes. Format-WatchFooter takes the two latch strings and an optional last-poll stamp, the optionality being the difference between the footer's two shapes: the startup pass has not polled yet, so a "Last poll at ..." line would be a lie. Pure, and previously unreachable -- all four of its branches lived inline in an infinite loop -- so this is where the coverage actually moves. Worth recording: writing those tests immediately caught a real defect in the new code. PowerShell unwraps [Nullable[DateTime]] to a plain DateTime on binding, so the .Value the parameter type suggests does not exist and the loop's footer would have thrown on every tick.
The -Warmup startup pass was the last block keeping Invoke-UsageWatch long: a refusal, a round-robin with an inline repaint closure, a title write, the early-repoll schedule and the cooldown seed, all inline and all unreachable by a test, because the only way in was a function that then never returns. Invoke-WatchStartupWarm takes the session and mutates it. Three nested "if the snapshot is not null" blocks collapse to one early return, since all three guarded the same condition. Seven tests now cover it with Invoke-WarmAllSlots mocked -- the real one spawns `claude -p` per slot and is billable. They pin the parts that cost the user something when wrong: the refusal fires before any slot is touched, the cooldown map is seeded so the first poll does not re-warm what the pass just warmed, a rate-limited pass reschedules early instead of leaving dashes up for a full interval, and a host that refuses the title write does not discard work already paid for. With the last phase landed, the work plan that described it goes too.
AGENTS.md sat at 18,484 bytes against its own 18,500-byte hard ceiling, so no new rule could be added without an unplanned split, made under pressure by whoever happened to need the space. The contracts it carried move into four reference documents and the file keeps one invariant per area, each ending in a pointer: 12,583 bytes against a target of about 12,000, which is the number that matters, the ceiling only being where review stops. The split also removes duplication the old layout had accumulated. CLAUDE_CONFIG_DIR, the 0600 and 0700 file modes, the state-file schema, name sanitization and the execution policy each existed in both AGENTS.md and README.md, and the CLAUDE_CONFIG_DIR advisory rationale was written twice in different words, which is what a second copy looks like shortly before it stops agreeing with the first. The cut is now by reader rather than by topic: README.md says what happens, docs/architecture.md says what the contract is and which function owns it, and the pointers run one way only so the two cannot loop. The Script actions table is deleted rather than moved, being a fourth copy of a list the ValidateSet, the help text and the README already carry; docs/documentation.md's routing table puts an inventory like that nowhere and points at its source instead. Two rules are new rather than moved. Security Rules states what a repository whose subject is live OAuth credentials had never written down: no token, slot filename or account uuid into a transcript, a scratch file, a fixture or a commit, and no side-effecting action against the real ~/.claude, warmup and monitor -KeepWarm being billable at roughly $0.004 a slot. It could not have been added before this commit; there were sixteen bytes left. A Reference table names each document and when to read it, which is what makes a moved rule findable rather than merely gone. docs/conventions.md settles two conventions the repository ran on without documenting either. Commits keep the Conventional Commits form even though Common Changelog argues against it in section 4.2, and the reason is recorded rather than left as an unexplained contradiction between two cited standards. CHANGELOG.md's four deviations from Common Changelog are stated with their reasoning, chiefly that it carries no commit or pull-request references where section 2.4.2 requires them; that is a decision here, not drift, and the one it forces on entry length is admitted in the same table rather than left for a reader to notice. American spelling becomes a rule. The hits in the files this commit already touches are fixed and released changelog entries are left alone, which is the rule's own instruction: it applies to what you touch, never as a sweep. Verified: 901 passed, 0 failed, 11 skipped (Unix-only modes, on Windows), coverage 94.57% against the 90% gate. All 18 cross-document pointers resolve to existing headings and every reference document opens with the sentence in its Reference cell.
The previous commit guarded [Console]::CursorVisible's getter on the
evidence that it carries [SupportedOSPlatform("windows")]. That evidence
was read off the reflection metadata, and the corrected line was then
verified by a regex over the source: Enter-WatchTerminal was the one watch
function no test had ever executed, on any platform. A static assertion
that a guard exists passes just as happily against a function nothing
calls.
Measuring it rather than reading about it turned up the other half. Off an
attached console the getter throws IOException and the setter
SetValueInvocationException, so the unguarded setter one line below was
the same landmine, and it was what kept the function untestable: every
test bounced off it.
Guard it, then drive both halves for real against the live [Console] with
only the output stream swapped. Running under Pester is itself the broken
condition, on every leg of the CI matrix, so the guards are now proven by
execution on Linux and macOS rather than described. Five tests replace the
regex: the entry survives a host with no console, emits the alt-buffer
switch and the cursor hide in one write, reports EnteredAlt so the restore
fires, records a failed capture as $null rather than a defaulted $false
that would leave the cursor hidden, and forces UTF-8 while putting the
previous encoding back.
The platform contract moves to docs/architecture.md -> Console APIs,
beside POSIX has no mandatory locking, which pairs a platform fact with a
test strategy the same way. The function comment keeps the terse why and
points there.
Every function the watch loop calls is tested on its own, and none of that covered whether the loop wires them together. Nothing could: the loop is `while ($true)` with no exit, behind a guard that refuses a redirected stdout, which is what a test host is by definition. The assembly was the last unexecuted part of the engine. Test-WatchInteractive wraps the [Console]::IsOutputRedirected probe for the same reason Test-ClaudeRunning wraps its own: a static cannot be mocked. Production behaviour is unchanged, and the three tests that assert the interactive refusal keep passing untouched, because they never mock it. The loop bound comes from Start-Sleep, the one call every tick makes regardless of branch, throwing a sentinel once the budget is spent. That unwinds through the real try/finally exactly as an unexpected failure would, so the restore assertions mean something. Enter-WatchTerminal, Exit-WatchTerminal and the renderers all run for real; only the poll boundary and the sleep are stubbed. Ten tests: the guard refuses a pipe before anything runs, the alt buffer is entered once and left once, the cursor restore is the last thing written even when the body throws, one frame is painted per tick, the poll gate fires once inside an interval but every tick while the screen is still empty, the waiting advisory gives way to the table, the mode latch reaches the frame through the footer, and a too-small interval is clamped with an advisory. Also folds the console-capture helper that had been copied into a second Context, and moves it to file scope where both users can reach it.
Pester runs with stdout redirected, because that is what a test host is. Under redirection the console cursor API is a no-op or throws and the alternate screen buffer is a string in a StringWriter, so the suite can prove the watch lifecycle does not crash and cannot prove it works. That distinction is not academic. It is exactly where the [Console]::CursorVisible bug lived: the getter is Windows-only, it aborted `sca usage -Watch` and `sca monitor` at startup on Linux and macOS, and no test reached it because the interactive guard refuses first whenever a test is watching. Every check added for it so far has run under the conditions that hide it. The probe re-enters itself under script(1), whose pseudo terminal makes IsOutputRedirected false. The real guard then passes and Enter-WatchTerminal runs against a real console handle, which is the line that was broken and the thing nothing has executed. It asserts the six VT markers bracketing a session: alt buffer in and out, cursor hide and restore, a frame paint, a title set. Only the network and credential boundary is stubbed, and CLAUDE_CONFIG_DIR points at a temporary directory so the operator's real login is never in reach. The run bounds itself by counted redraws rather than a clock, so it cannot flake on a slow runner; timeout-minutes is a backstop against a hang, not the mechanism. util-linux and BSD script differ in argument order and the BSD one has no -e, so the verdict comes from the recorded output rather than an exit status that is not comparable between them. Windows has no script(1) and ConPTY is disproportionate for one probe, so the probe exits 0 there and Windows keeps the suite and the coverage gate. Not verified from here: this was written on Windows, so the script(1) invocations are unexercised and the first CI run may need a flag fix.
The five manual checks for the watch lived in a work-plan document that was deleted the moment the refactor it described landed. That is how they got lost, and the watch will be changed again. They go beside the OAuth-constant drift note, which they structurally match: both say what passing tests do not prove and name the command that would. The list is shorter than it was, because the real-terminal probe now machine-checks the alt buffer, the cursor restore, the title and the frame paint. What is left is what needs eyes, which is a frame's appearance: a flicker is a property of two frames a few milliseconds apart, and a wrong glyph is still a character. Ctrl-C is on the manual list rather than in the suite because PowerShell models it as a pipeline stop, not a terminating error. The loop tests unwind through the same try/finally by throwing, which is a proxy and not the thing, and saying so is worth more than implying the coverage is complete.
The 31 commits since v4.0.0 add hot-swapping a live Claude Code session and carry no breaking change, so the accumulated Unreleased section becomes 4.1.0 rather than a patch or a major. Two corrections went in with the rename. The section listed Added before Changed, where docs/conventions.md fixes the order at Changed, Added, Removed, Fixed and every other section in the file follows it. And the console-cursor guards had no entry at all: the getter on [Console]::CursorVisible is Windows-only and aborted `sca usage -Watch` and `sca monitor` at startup on the two platforms 4.0.0 had just added. That is the most severe defect in the release and the one a reader upgrading from 4.0.0 most needs to see. The version constant moves with the heading because a Helpers.Tests.ps1 case pins it to the first release header in CHANGELOG.md; the two cannot land separately.
The real-terminal probe sets `Set-StrictMode -Version Latest` before
dot-sourcing the script, so the renderers it drives run under semantics
nothing else here uses: a missing property throws rather than yielding
$null. Its stub snapshot carried buckets of `{ utilization }` alone and
`ConvertTo-UsageTableRow` reads `resets_at` off both, so the first frame
render died, the DEC 2026 envelope after it was never written, and the
probe reported the watch as never having reached the end of its run.
Windows did not see it, having no script(1) and exiting early.
`/api/oauth/usage` serves a bucket as `{ utilization, resets_at }`, the
reset nullable but present (`docs/claude-code-internals.md`). The fixture
now says the same.
Update-SlotTokens propagates a refreshed slot's tokens into .credentials.json whenever that slot is the tracked one. It is reached from `sca usage` and from every monitor poll, and neither of those refuses on Invoke-Reconcile's `Captured = $false`, so bytes a reconcile had deliberately declined to attribute were overwritten anyway; the last_sync_hash update behind it then erased the evidence that they had ever been unreconciled, which is why no later pass repaired it. The propagation now compares .credentials.json against the hash the last reconcile recorded and leaves the file alone on a mismatch. Only a PROVEN mismatch blocks it, matching the rule the identity guard already follows: a file that cannot be hashed, or a state carrying no hash to compare against, must not be able to freeze the mirror. Invoke-Reconcile's adopt branch holds an adoption back when ~/.claude.json names an account the adoption would go stale against, and decided that on the email resolved at the top of the pass. Get-OAuthAccountFromClaudeJson answers $null for an unreadable file exactly as it does for an absent one, and an unreadable file is itself the likeliest reason the identity write above it threw, so the two failures are correlated and the guard read "nothing to disagree with" in the one case most likely to disagree. The existing test covered only the benign shape: valid JSON carrying no oauthAccount. Read-ClaudeJson now reports absent / unreadable / ok and the resolver is re-expressed on top of it, so the parse keeps one home and the adoption stands only where the absence of an identity is proven.
ConvertTo-AuthVerdictMap validated that a stored verdict carried a status and a cred_hash, never that the status was one sca could have written. Every reader downstream treats the value as already trustworthy: Resolve-AuthVerdictResult hands it straight to New-UsageResult's ValidateSet, so a status from another version would throw a binder error out of a Get-SlotUsage whose contract is that it never throws, aborting the reading for the whole pool rather than degrading one row. An 'ok' is worse for being accepted: it passes the set, carries no Data, scores 0% in Get-RowMaxUtilization and so presents a slot nothing can be read from as the preferred rotation target, which is the hazard this release already closed for data-less cache entries. The accepted pair moves to $Script:AuthVerdictStatuses, read both by the map and by the single writer in Invoke-WarmAllSlots, so the set has one home rather than a literal at each end. Narrow on purpose: expired and unauthorized are the only conclusions claude -p can actually prove, and a later widening should be a deliberate edit here. Two comments that had outlived their code go with it. The backoff stamp in Get-SlotUsage still called itself a no-op without a prior entry, which this release inverted when Set-SlotRateLimitBackoff began creating throttle-only entries, and a reader lands on that call site precisely to learn what it does. Enter-WatchTerminal's docblock claimed both CursorVisible halves were guarded; both of ITS halves are, and Exit-'s restore is not, so the sentence now says which function it is describing and why the unguarded write there is confined to a console that has already answered.
The changelog gains the propagation and adopt entries. Both are the kind a reader upgrading cares about: they change when sca declines to write, and one of them is the path `sca usage` reaches, which nobody thinks of as a writing command. Two README claims were wrong rather than merely thin. The `sca switch` refusal transcript ended "or run 'sca save work' to capture them now", dropping both the precondition and the condition the emitted string deliberately carries: the save is worth trying only if the identity stays unresolved while you are online, and it refuses outright while Claude Code is open. Printing it bare sends the reader to a command that will refuse them for the reason they are already stuck on, which is exactly what the wording in Get-UncapturedCredentialsRefusal was written to avoid. And the ~/.claude.json lock note priced the exposure at "a counter or a project flag". That file carries per-project prompt history and mcp configuration, as the 2.0.0 entry describing the targeted substitution already says; the note now agrees with it. Still true, and still the point of the paragraph, that no credential is at stake there.
Set-OAuthAccountInClaudeJson reads the whole file, locates the oauthAccount block by brace-counting, substitutes the whitelisted fields and writes the result back. Claude Code takes ~/.claude.json.lock, re-reads under it and merges, so its writes never clobber ours; ours clobber anything it committed while we were transforming. That used to be unreachable, because every caller refused to run beside a live client. This release removed that refusal from switch and from monitor, which makes the race routine rather than impossible, and the file carries configuration and per-project prompt history. Re-read immediately before committing and start the substitution over when the bytes moved, three times, matching Set-CredentialFileAtomic's rename policy and for the same reason: a contender still winning after three tries is not a blip worth waiting out. Then give up rather than overwrite. Both callers already handle a throw, and the cost of refusing is a stale /status email that the next sca switch repairs, against the cost of proceeding, which is someone else's data. This narrows the window from the whole substitution, a regex and a brace scan over an 18 KB+ file, to the gap between the verify read and the rename. It does NOT close it. Taking the lock is the real fix; its naming, staleness and timeout semantics are unextracted, and guessing at them inside this path would be worse than the race it replaces. The transform moves to ConvertTo-UpdatedClaudeJson unchanged, which is what makes re-running it against fresh bytes expressible, and lets the brace scan be driven without a file. The tests reach the race by mocking that seam and having the mock move the file underneath, rather than by mocking Get-Content, which every sandboxed test in the suite depends on reading for real.
The file had drifted into being a second copy of the commit bodies. Mean entry length was 231 characters against the 101 of the project this style is taken from, 4.1.0 alone averaged 776, and one entry ran to 2182 characters describing six separate changes. A reader deciding whether to upgrade had to read an essay to find out whether anything affected them. Two deviations go with it. Entries no longer run past one line, and version headings now carry a release link through a reference-link block at the foot of the file; all fifteen resolve, since every shipped version has both a tag and a published release. Four deviations become two: no per-entry commit or pull-request reference, and an Unreleased section. Dropping the long form was worth checking rather than assuming, because the old fourth deviation claimed the changelog was the only place the reasoning could live. It is not, and the measurement says so: commit-body coverage and entry length move in opposite directions across the history. From 2.0.0 up, where the entries were long, the commits of that era carry 550-650 characters of body each, and the 4.1.0 ones carry 1642-2871. At 1.0.0 and 1.1.0, where only 3 of 16 and 7 of 22 commits have a body at all, the entries were already at 101 and 145 characters and had nothing to lose. There is no version where the changelog was both the sole record and verbose. Also: eleven of the fifteen sections ran Added before Changed, which the category order in docs/conventions.md has always said otherwise about, and every line was being touched anyway. Five headings gain an italic upgrade note, for the versions where upgrading is more than replacing the file. All ten BREAKING markers are preserved. The one entry over 200 characters is the `sca usage -Json` shape, kept long deliberately: a consumer parsing that output needs the detail, and it is reference material rather than a description of a change. 174 entries, mean 97 characters, longest 236. The file drops from 43 KB of entry prose to 20 KB.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This release makes
scausable beside a running Claude Code.sca switchandsca monitorno longer refuse whileclaudeis open, and getting there meant teaching the reconcile pass to prove whose tokens it is about to file before it writes, which closed the paths that could overwrite or discard a saved login. Upgrading is still replacing one file, but a hot swap is only picked up without a restart by Claude Code >= 2.1.274 oropencode-claude-auth>= 1.5.4, and the pinnedUser-Agenton the token endpoint moves toclaude-code/2.1.278.Two upgrade notes beyond replacing the file.
sca switchgained a refusal, and with it a non-zero exit on a path that previously always succeeded: it now stops when the reconcile ahead of it could not attribute the active credentials. A wrapper treating a failedsca switchas fatal has a new way to fail. And anyone who triedsca usage -Watchorsca monitoron Linux or macOS in 4.0.0 found it aborted at startup; that is fixed here.Nothing in this range is a breaking change.
4.1.0
sca switchandsca monitorto run beside an open Claude Code. Onlysave,warmupandmonitor -KeepWarmstill refuse, each because it either makes every slot active in turn or pairs two files that a/loginwrites separately.sca usage -Watchandsca monitoraborting at startup on Linux and macOS, where the Windows-only getter on[Console]::CursorVisiblethrew before the first frame was painted./api/oauth/profileusing the incoming tokens before the slot is written..credentials.jsonnow checks that the reconcile ahead of it actually captured what was there, and refuses or abandons the tick when it did not.sca usageand every monitor poll mirror a refreshed slot into.credentials.jsonwithout refusing onCaptured = $false, and the sync-hash update behind that mirror erased the evidence, which is why no later pass repaired it.~/.claude.jsonis unreadable. The guard decided on an email whose resolver answers nothing for an unreadable file exactly as for an absent one, and an unreadable file is itself the likeliest reason the identity write it reacts to failed.sca switchandsca monitorsilently dropping a~/.claude.jsonchange Claude Code made mid-substitution. Removing the refusal made that read-modify-write routine rather than impossible; the file is now re-read immediately before the write and the pass restarts, then gives up rather than overwrite.sca listhides andsca removecannot reach.~/.claude.jsonidentity write ahead of the state-file adoption, so a failure leaves both files agreeing and the next run retries rather than permanently mislabeling a slot.claude -pconcluded and keying it on a hash of the credential file so a re-login retires the record with nothing to clear by hand. Only the two verdictsclaude -pcan actually prove are accepted back off disk.429in the same run, roughly halving the cost of asca usageacross throttled slots.429, measured at 360 requests an hour for two slots at the default interval.User-Agenttoclaude-code/2.1.278and restored thescopefield the client has always sent.Invoke-UsageWatchandFormat-UsageTableinto model, measure and render, and gave the watch loop's mutable state a single owner.docs/, leavingAGENTS.mdas orientation carrying one invariant per area.Shortcomings
~/.claude.jsonstill holds the previous account's uuid inside the two-write window so the offline comparison answers "same", andTest-CredentialAccountMatchreturnsunknownbecause the probe cannot reach anything.unknowndoes not overturn, by design, so the mirror proceeds and writes the new account's tokens into the old account's slot. Makingunknownblock instead would freeze slot files for every offline user, which is the worse failure. It is the one overwrite path this release leaves open and it is worth arguing about.~/.claude.jsonrace is narrowed, not closed. The compare-and-swap shrinks the window from the whole substitution to the gap between the verify read and the rename. The real fix is taking~/.claude.json.lock, whose naming, staleness and timeout semantics are unextracted; guessing at them inside that path would be worse than the race.statcadence, so the central claim of this release can rot silently.sca usage, which cannot run in CI because it needs real credentials.429analysis behind the one-attempt collapse rests on measurements taken on a single day from a single network vantage. If the edge behaves differently elsewhere, the collapse gives up a recovery the three-attempt ladder would have won.Feedback I want
unknownshould block, say so; the change is small and the consequence for offline users is large.4.1.0. No commit carries a breaking marker, butswitchgaining a refusal changes what an existing wrapper observes.What is not done
release-assets.ymlfires on publish, so the standalone.ps1asset appears only once this merges andv4.1.0is created.mainintodevelopafterwards.~/.claude.json.lockis not taken. Top follow-up for 4.1.1: extract the protocol perdocs/claude-code-internals.md, then replace the compare-and-swap with real mutual exclusion.Test-TokenEndpointThrottledanswers from a field two endpoints stamp; three unguarded or mis-scopedtryboundaries in the watch lifecycle and the keep-warm step;auth_verdictsentries are never pruned when a slot is removed; and the mid-loop reconcile inInvoke-WarmAllSlotsdoes not readCaptured, which is unreachable there becausewarmuprefuses beside a live client.sca usageon its own still cannot tell a revoked grant from a throttled one. It needs asca warmupto have recorded a verdict first; the advisory names that workaround rather than fixing the gap, because the endpoint offers no way to ask.sca saveandsca warmupstill depend on the user closing the client there.