Skip to content

Release v4.1.0 - #20

Merged
countzero merged 37 commits into
mainfrom
develop
Sep 19, 2026
Merged

countzero merged 37 commits into
mainfrom
develop

Conversation

@countzero

@countzero countzero commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

This release makes sca usable beside a running Claude Code. sca switch and sca monitor no longer refuse while claude is 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 or opencode-claude-auth >= 1.5.4, and the pinned User-Agent on the token endpoint moves to claude-code/2.1.278.

Two upgrade notes beyond replacing the file. sca switch gained 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 failed sca switch as fatal has a new way to fail. And anyone who tried sca usage -Watch or sca monitor on 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

  • Allowed sca switch and sca monitor to run beside an open Claude Code. Only save, warmup and monitor -KeepWarm still refuse, each because it either makes every slot active in turn or pairs two files that a /login writes separately.
  • Fixed sca usage -Watch and sca monitor aborting at startup on Linux and macOS, where the Windows-only getter on [Console]::CursorVisible threw before the first frame was painted.
  • Stopped reconcile overwriting a saved slot with another account's tokens. Sameness is decided on the account uuid rather than an email read from a different file, byte-identical bytes are adopted instead of mirrored, and anything else is confirmed against /api/oauth/profile using the incoming tokens before the slot is written.
  • Ran that identity probe on every host rather than only where a live client is detectable, because the process guard is blind to an npm-installed Claude Code on Windows and macOS, which is where the overwrite would otherwise have survived.
  • Closed the paths that discarded a token refresh. Every action that overwrites .credentials.json now checks that the reconcile ahead of it actually captured what was there, and refuses or abandons the tick when it did not.
  • Stopped a token refresh propagating onto active credentials nothing had captured. This is the same contract as the line above, at the one write that was not covered by it: sca usage and every monitor poll mirror a refreshed slot into .credentials.json without refusing on Captured = $false, and the sync-hash update behind that mirror erased the evidence, which is why no later pass repaired it.
  • Stopped reconcile adopting a byte-identical slot while ~/.claude.json is 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.
  • Stopped sca switch and sca monitor silently dropping a ~/.claude.json change 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.
  • Made reconcile decline to write when it cannot attribute the new bytes to an account, on the untracked path as well as the tracked one, instead of minting a slot file with no identity sidecar that sca list hides and sca remove cannot reach.
  • Stopped reconcile refreshing the tokens it was only asking about, which rotated a refresh token out from under the client this release now lets keep running.
  • Ordered the ~/.claude.json identity 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.
  • Reported a login that needs re-authenticating as such instead of as rate-limited, recording what claude -p concluded 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 verdicts claude -p can actually prove are accepted back off disk.
  • Collapsed the three-attempt token-refresh ladder to one attempt once another slot has drawn a 429 in the same run, roughly halving the cost of a sca usage across throttled slots.
  • Extended the rate-limit backoff to slots holding no cached reading, which were precisely the ones re-running that ladder against an endpoint already answering 429, measured at 360 requests an hour for two slots at the default interval.
  • Doubled the keep-warm cooldown per consecutive failed warm, so a throttled slot stops costing a billable attempt every five minutes for the life of the watch.
  • Re-pinned the token endpoint's User-Agent to claude-code/2.1.278 and restored the scope field the client has always sent.
  • Added a CI step that drives the watch through a real pseudo terminal on Linux and macOS, which is what surfaced the cursor defect the redirected-stdout suite could never reach.
  • Split Invoke-UsageWatch and Format-UsageTable into model, measure and render, and gave the watch loop's mutable state a single owner.
  • Moved the repository's rules into docs/, leaving AGENTS.md as orientation carrying one invariant per area.

Shortcomings

  • The mirror can still be wrong offline, and this is a deliberate trade. Re-login to an account that is not yet saved, with no network: adoption cannot fire (no byte-twin), ~/.claude.json still holds the previous account's uuid inside the two-write window so the offline comparison answers "same", and Test-CredentialAccountMatch returns unknown because the probe cannot reach anything. unknown does not overturn, by design, so the mirror proceeds and writes the new account's tokens into the old account's slot. Making unknown block 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.
  • The ~/.claude.json race 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.
  • Hot-swapping is verified by one manual two-account experiment against Claude Code 2.1.274, not by CI. Nothing here detects a future build changing its polling or stat cadence, so the central claim of this release can rot silently.
  • The unofficial OAuth constants are re-pinned by reading the 2.1.278 binary. The suite asserts response shape only; whether the constants still work is answerable only by a live sca usage, which cannot run in CI because it needs real credentials.
  • The 429 analysis 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.
  • The watch decomposition is a large pure refactor riding inside a release. It is covered by tests, but it is not what a release reviewer expects to have to read, and it is roughly two thirds of the diff.

Feedback I want

  • Argue with the offline mirror trade above. It is the one place a saved login can still be overwritten, and I chose availability over caution knowingly. If unknown should block, say so; the change is small and the consequence for offline users is large.
  • Sanity-check the compare-and-swap. It gives up after three attempts and throws, and both callers turn that into an advisory rather than a failure. I believe refusing beats overwriting someone else's prompt history, but the failure is silent-ish by design and that deserves a second opinion.
  • Argue with 4.1.0. No commit carries a breaking marker, but switch gaining a refusal changes what an existing wrapper observes.

What is not done

  • No tag and no GitHub release yet. release-assets.yml fires on publish, so the standalone .ps1 asset appears only once this merges and v4.1.0 is created.
  • No back-merge of main into develop afterwards.
  • ~/.claude.json.lock is not taken. Top follow-up for 4.1.1: extract the protocol per docs/claude-code-internals.md, then replace the compare-and-swap with real mutual exclusion.
  • Eight low-severity findings from the review pass are deferred to 4.1.1, all cases where the code reads wrong rather than behaves wrong: the keep-warm failure counter reads the verify read's status rather than the activation's (the cooldown ceiling of 160 min sits inside the 5h window, so no slot is starved); Test-TokenEndpointThrottled answers from a field two endpoints stamp; three unguarded or mis-scoped try boundaries in the watch lifecycle and the keep-warm step; auth_verdicts entries are never pruned when a slot is removed; and the mid-loop reconcile in Invoke-WarmAllSlots does not read Captured, which is unreachable there because warmup refuses beside a live client.
  • sca usage on its own still cannot tell a revoked grant from a throttled one. It needs a sca warmup to have recorded a verdict first; the advisory names that workaround rather than fixing the gap, because the endpoint offers no way to ask.
  • The process guard remains blind to an npm-installed Claude Code on Windows (reading command lines costs about 53 s) and on macOS (PowerShell does not expose them). The identity probe now contains the damage, but sca save and sca warmup still depend on the user closing the client there.
  • 11 tests skip on the Windows leg. They are the Unix file-mode and inode assertions and they do run on the Linux and macOS legs, so no assertion is skipped everywhere.

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.
@countzero
countzero merged commit e33b3a2 into main Sep 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant