Skip to content

Release v4.2.0 - #21

Merged
countzero merged 25 commits into
mainfrom
develop
Sep 21, 2026
Merged

countzero merged 25 commits into
mainfrom
develop

Conversation

@countzero

@countzero countzero commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

This release lets the warm round-robin run beside an open Claude Code, closes the paths where that round-robin could lose a token it had just caused to rotate, and adds SCA_THEME. Upgrading is replacing one file, and nothing here is marked breaking.

Two things change what you see without you asking for them. sca warmup and sca monitor -KeepWarm no longer refuse while claude is open: the first warns and gives you a five-second Ctrl-C window before it starts spending money, the second carries the same sentence in its footer and cannot pause at all. And the Session aggregate bar now drops any slot whose week has capped, denominator included, so on a pool with a week-capped slot it prints a different number than 4.1.0 did. Colors are unchanged unless you set SCA_THEME yourself.

4.2.0

Running beside a live client

  • Allowed sca warmup and sca monitor -KeepWarm to run while Claude Code is open. Reading claude.exe 2.1.278 is what settled it: the OAuth refresh sits behind request paths only, never a timer, so an idle client never refreshes on its own, and the refresh itself takes a cross-process lock, re-reads the credentials before and under it, and returns "refreshed" when the access token moved. Two processes on one account adopt a result rather than race for it.
  • Warned, then paused sca warmup for five seconds before the first billable activation when it finds a live client, because what remains is a prompt sent mid-pass billing whichever slot is mounted. The warning alone was a label rather than a decision, since the first claude -p followed it by milliseconds.
  • Held both the warning and the pause until a slot is known to match, so sca warmup on a fresh install, or with a -Name matching nothing, no longer warns about billing and blocks for five seconds before reporting that it has nothing to activate.
  • Carried the same sentence into monitor -KeepWarm as a footer latch at startup and again at every re-warm, since a client opened at hour three of a watch is dragged across every account by the very next pass.
  • Left sca save as the one action that still refuses, for a reason the round-robin never shared: it pairs tokens from .credentials.json with an identity read from ~/.claude.json, and nothing later corrects a mislabel.

The loss that needed no live client

  • Mirrored .credentials.json back into the slot after every activation rather than only an ok one. claude can refresh a grant and only then be turned away, hitting the 5h limit being the common case, and the next iteration's swap overwrote those tokens and left the slot on a rotated refresh token.
  • Moved that reconcile into a finally, so an activator that throws after claude -p has already refreshed still files what it landed instead of having it swallowed by the loop-body catch.
  • Made the pass read the reconcile's Captured field and stop when it is false, and skipped the restore along with it, because that restore is one more overwrite of exactly the bytes the stop exists to protect.
  • Gated the auth verdict on the same reconcile. A verdict asserts that claude proved a grant dead, which holds only if claude wrote nothing; recording one otherwise stranded a working slot behind a verdict that outlived the run.
  • Decided the abort before the repaint rather than after it. The repaint is the one statement in the loop body outside a catch, and a throw there unwound straight to the finally with the guard still unset, so the restore ran and overwrote the credentials nothing had captured: the guard's own blind spot reaching the exact loss it exists to prevent.
  • Finalized the rows an aborted pass never reached as skipped instead of leaving them at the seeded warming-up. Invoke-KeepWarmStep counted that as a failed warm and Get-WarmupCooldownMinutes doubled on the count, so a slot the pass never entered could be held off for up to 160 minutes, and the final table rendered it as an in-progress transient in a command that had already exited.
  • Surfaced the restore-failure advisory instead of writing it to 6>$null, the only such line in the file, which sent it to a sink a test mock could still see and a user could not. It rides back on the snapshot now, and each caller renders it on the surface it owns.

The Session aggregate

  • Dropped a slot at the 100% Week cap out of the Session aggregate bar entirely, denominator included. A 5h window is nested inside the 7d one, so while the week is capped none of that slot's session capacity is reachable, and its idle Session cell was being counted into the pool as headroom.
  • Kept the same slot on the Week bar at its real 100%, because dropping it there would hide the exhaustion the bar exists to show. The rule runs one way only: a capped 5h window costs the week at most 5h of 168.
  • Returned 100 rather than blanking the bar and the title when the week has capped every measurable slot, so the pool reads as spent at the moment that matters most.
  • Corrected the README's claim that a full Session bar means the week has capped every slot, and re-rendered the two SVGs and the watch-title example whose numbers moved.

Theming

  • Added SCA_THEME, resolved once per run, which pins output to an exact palette instead of the terminal's own ANSI colors. Precedence is -NoColor > NO_COLOR > SCA_THEME > default: naming a theme says which colors, not whether.
  • Renamed Write-Color's seven color tokens to the semantic roles its docblock already described and moved the role-to-SGR mapping out of the function, so a palette swap touches no call site. Taking PowerShell color names would have made every call site a lie the moment a theme mapped DarkYellow to blue.
  • Shipped ten themes: nine base16 schemes transcribed from tinted-theming/schemes, plus an original claude. The role-to-slot rule is stated once, so a theme is a row of seven hex values and adding one is a data change the integrity tests pick up with no edit of their own.
  • Painted the alternate screen that usage -Watch and monitor own in the theme's background, re-asserted after every reset and before each erase so it fills through back_color_erase, with repeated runs collapsed so a 1 Hz repaint carries no redundant bytes. Scrollback is excluded deliberately: a background there would leave ragged colored bars in the shell history for good.
  • Left the default palette palette-relative, spelled as named ANSI, so it keeps following the user's own terminal scheme and stays legible on a light background. Only an explicitly named theme burns in truecolor.
  • Added docs/themes.md, one heading and one full rendered sca monitor view per theme, and a README section pointing at each anchor.
  • Listed the theme names in sca help under a new ENVIRONMENT section.

Elsewhere

  • Guarded the watch's console cursor restore like every other System.Console call, so a failure there cannot unwind the terminal restore that follows it.
  • Raised the coverage gate from 90% to 97% and closed the reachable gaps behind it.
  • Dropped the CLAUDE.md import shim, now that Claude Code 2.1.277 reads AGENTS.md directly in a project without one.

Shortcomings

  • The central claim of this release is an observation about someone else's binary. That a warm pass cannot race a live client for a grant comes from reading the refresh paths and the cross-process lock in claude.exe 2.1.278. Nothing in CI detects a future build changing that, and if it does the failure is silent: a slot left holding a refresh token the server has already rotated.
  • The five-second pause is a weak gate, chosen over a prompt. A prompt needs a rule for redirected stdin and this is a command people put in a scheduler, so sca warmup blocks briefly instead of asking. Anyone running it non-interactively gets the warning and no way to act on it.
  • monitor -KeepWarm cannot pause at all. It carries the sentence in the footer latch and nothing more, which is the weaker half of the interlock on the repeating one of the two commands.
  • The Session bar now reads better as the pool gets worse. Shrinking the denominator is the correct answer to "of the capacity I can still reach, how much is spent", but it means the number improves as slots fall out. The Week bar and the red rows are what signal that instead, and a reader has to know the indirection is there.
  • The theme gallery costs about 4.1 MB. Every panel embeds JetBrains Mono, roughly 365 KB each, because freeze emits no per-glyph positions or textLength: a substituted face moves the text off the rect geometry freeze computed, which shows first on the block glyphs the usage bars are made of. There is no cheaper correct option in the current renderer.
  • Themes are dark-only and their Muted contrast is not corrected. Muted keeps base16's base03 ("Comments") rather than base04 ("status bars"), because base04 sits close enough to base05 that the row stops reading as de-emphasized. The resulting contrast runs 1.7:1 to 3.8:1 across these schemes, which fails WCAG at the low end.
  • Nothing probes for truecolor support. COLORTERM and TERM are both unset in a Windows truecolor terminal, so a probe would answer wrong on the primary platform. A named theme therefore emits truecolor unconditionally and degrades to approximation on a 256-color terminal.
  • An unknown SCA_THEME falls back silently. A typo lives in a shell profile, so a warning would print on every invocation for as long as it sat there. The trade is a discoverable error for a quiet wrong answer.
  • The unofficial OAuth constants remain shape-tested only. Whether they still work is answerable solely by a live sca usage, which CI cannot run because it needs real credentials.

Feedback I want

  • Sanity-check the abort ordering in Invoke-WarmAllSlots. The guard now settles its state immediately after the mirror and leaves the break after the repaint, so the frame still shows the row the pass stopped on. Confirm there is nothing left between the mirror and the flag that can throw, because that gap is exactly the bug this fixes.
  • Argue with dropping a week-capped slot from the Session denominator. It is the one number in the tool that now improves as the pool degrades, and it reverses a decision made earlier in this same cycle rather than an old one. If scoring it 100 was right, the revert is small.
  • Argue with the silent fallback on an unknown SCA_THEME. I chose quiet over a warning that repeats forever. The opposite case is that a theme which never applies and never says why is worse than a repeated line.
  • Argue with 4.2.0. No commit carries a breaking marker, but warmup and monitor -KeepWarm lose a refusal, so a wrapper that treated their non-zero exit as "Claude Code is open" now gets a run it did not expect.

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.2.0 is created.
  • No back-merge of main into develop afterwards.
  • The matrix is green, but it first saw this branch here. tests.yml restricts push to main on purpose, so a develop push with no open pull request runs nothing, and none of these commits had been through CI when the pull request opened. The first run failed both Unix legs on two tests this range added, and finding that is the whole argument for the trigger staying as it is rather than the gap being widened. Now: Windows 1033 passed at 98.7% coverage against the 97% gate, Linux and macOS 1042 passed, 0 failed anywhere.
  • The two failures were test-only and are fixed in 96b7500. Both Unix twins chmod a slot pair to 000 to force the snapshot read to fail, then chmod it back in a finally. Mode 000 denies the read but not the unlink, so the save deleted the pair as an obsolete sibling and the finally threw FileNotFoundException before either assertion ran. The Windows twins hold a FileShare::None handle, which blocks the delete too, so no Windows run could ever have seen it. The restore is now guarded on the path surviving, and both assertions execute for the first time.
  • 16 tests skip on the Windows leg, 7 on each Unix leg. They are the Unix file-mode, inode and command-line-probe assertions and their Windows share-mode twins, so no assertion is skipped everywhere.
  • ~/.claude.json.lock is still not taken, so the compare-and-swap in the substitution path stays narrowed rather than closed. Carried over from 4.1.0 and still the top follow-up.
  • Three findings deferred from 4.1.0 are still open: the keep-warm failure counter reads the verify read's status rather than the activation's (the skipped carve-out added here does not change where the status comes from); Test-TokenEndpointThrottled answers from a field two endpoints stamp; and auth_verdicts entries are never pruned when a slot is removed.
  • Light themes are not shipped. The chrome path handles one correctly and a light scheme is legible, but shipping both doubles a list that is read at a glance.
  • github is deliberately absent from the theme list despite having an upstream base16 definition. All three dark variants put orange in base08 and pale blue in base0B, so Danger would have rendered orange and Success blue and a glance at the status table would have misread which slots were healthy. The suite now hue-checks both slots on every scheme so a theme added later cannot reintroduce it.

The Session bar and the monitor title averaged each bucket independently, so a slot sitting at the 100% Week cap contributed its idle 5h reading to the pool as headroom. A three-slot pool with two slots refusing prompts rendered a 33% Session bar, and the aggregate contradicted the rotation engine, which already scores such a row at 100 through Get-RowMaxUtilization and refuses to enter it.

A 5h window is nested inside the 7d one, so while the week is capped none of that slot's session capacity is reachable and the pool has none of it left to offer. The rule runs one way only: a capped 5h window costs the week at most 5h of 168, so the Week mean keeps each row's own number.

The README watch and monitor examples carry a 'limited 7d' row, so their Session bar moves from 22% to 40% and the two SVGs are re-rendered.
…mbers

The bare -Watch section printed 22% | 62% as its terminal-title example, which are the pool-aggregate bar numbers. Invoke-UsageWatch passes -Aggregate only under -Auto, so a bare -Watch title carries the active slot, exactly as the note directly beneath the example already says. The example's active row is 'work' at 18% / 42%.
…S.md

Claude Code 2.1.277 reads AGENTS.md directly in a project that has no CLAUDE.md, so the one-line @import shim no longer carries anything. The feature's Bedrock / Vertex / Foundry carve-out does not reach this repo, which manages claude.ai subscription OAuth slots.

The cost is that a client below 2.1.277 now loads no project instructions at all, silently and with no error, including the security rules governing live credentials. Accepted deliberately.
2063582 scored a week-capped slot as 100% on the Session bar, which answers 'of nominal pool capacity, how much is gone'. That is the Week bar's job, and it left the bar contradicting the Session cells printed underneath it. The Session bar answers a different question, 'of the session capacity I can still reach, how much is spent', so a slot the week has capped now leaves that average entirely, denominator included.

The cells keep reporting what Anthropic measured, including the idle 0% on a capped slot, so no rendered number asserts a reading the API did not give. The Week bar keeps a week-capped row at its real 100%: dropping it there would hide the exhaustion the bar exists to show. The rule stays one-way, since a capped 5h window costs the week at most 5h of 168.

Accepted cost: the Session average improves as slots fall out of the pool. The shrinking pool is signalled by the Week bar and the red rows instead. When the week has capped every measurable slot the average would have no rows left, so it returns 100 rather than blanking the bar and the title at the moment they matter most.

The README example's Session bar moves from 40% to 25% over its four reachable slots, and the two SVGs are re-rendered.
`sca warmup` and `sca monitor -KeepWarm` refused while Claude Code was running.
Both make every slot active in turn, and the concern was that the `claude -p` a
warm pass spawns would race the live client for a cold slot's grant, leaving one
of them holding a refresh token the server had already rotated.

It cannot, and claude.exe 2.1.278 says why. The OAuth refresh has no timer
behind it, only request paths: 401 recovery, the bearer-attribution preflight,
the request-header build and a poll auth check. An idle client never refreshes
on its own. The refresh itself then takes a cross-process lock, re-reads the
credentials both before and under it, and returns "refreshed" when the access
token moved, adopting a peer's result rather than racing it. Anthropic
instruments that path as tengu_oauth_token_refresh_race_resolved. Two Claude
Code processes on one account cannot both rotate.

The loss that did exist was sca's own, and it needed no live client at all.
Invoke-WarmAllSlots mirrored .credentials.json back into the slot file only
after an 'ok' activation, but claude can refresh a grant and only then be
turned away, hitting the 5h limit being the common case. The next iteration's
swap overwrote those tokens and left the slot on a rotated refresh token. The
mirror now runs after every activation; it costs nothing when nothing moved,
because the swap stamped state.last_sync_hash with the bytes it wrote.

That same reconcile now gates the auth verdict. A verdict asserts claude proved
a grant dead, which holds only if claude wrote nothing: had its refresh gone
through, the grant is alive and the refusal was about something else. Recording
one then stranded a working slot behind a verdict that outlived the run.

What remains is a prompt sent mid-pass billing whichever slot is mounted, a
surprise rather than a loss, so `sca warmup` warns instead of refusing. `save`
still refuses, for a reason the round-robin never shared: it pairs tokens with
an identity read from a different file, and nothing later corrects a mislabel.
Scratch artifacts belong under .tmp/sessions/<session-id>/, but nothing here
supplied an id, so rule 3 fell through to its minted YYYYMMDD-HHMMSS-<random6>
fallback on every run and a resumed session opened a second directory instead of
reusing its own.

Both clients can hand one over, by different routes. OpenCode gets a plugin on
the shell.env hook, putting SESSION_ID into the environment of every command;
Claude Code gets a SessionStart hook whose stdout joins the conversation ahead
of the first prompt. Neither puts the value in the system prompt, which is the
one block every turn and every session share and which a prompt cache keys on:
a per-session value there leaves no two sessions a reusable prefix and has the
whole block reprocessed at each session start. The plugin comment carries that.

The Claude Code hook shells out to jq, which this repo otherwise does not depend
on. A pwsh equivalent costs a process start per session and reads far worse, and
an absent jq degrades to the minted fallback rather than breaking a session.
…verdict

Both files handed an agent the run command and nothing about reading it. The
suite prints a line per test under -Output Detailed, so the summary and the
coverage line sit at the bottom of roughly a thousand, and the obvious reflex is
to pipe the run through a filter sized for a terminal. That cuts exactly the two
lines worth keeping, and the only way back to them is a second full run of a
suite that takes minutes. It cost one here.

The runner has answered this since it was written: it exits 1 if any test failed
or coverage missed the gate, 0 otherwise. Asking for $LASTEXITCODE in the same
command is one run, and it survives any truncation a harness applies, which
writes the full output to a file anyway.

Stated as the recipe first and the prohibition second, because a command gets
copied and a rule gets skimmed.
…tring

PowerShell's binder converts $null to '' on the way into a [string]
parameter, AllowNull or not, so the guard could never be entered and no
caller could ask for a JSON null. Verified against 7.6.6. The reason it
cannot happen now sits on the function instead, where the next reader
tempted to re-add it will look.

Test-ClaudeNodeProcess's docblock claimed the opposite of what it
should: that a Get-Process mock cannot reach a dot-sourced function.
It can, against Pester 5.7.1. What actually kept Test-ClaudeRunning
unexecuted is the suite's own blanket mock of it, and the real reason
for the split is that the caller reaches this probe only on Unix.
… call

Exit-WatchTerminal wrote [Console]::CursorVisible unguarded, the only
System.Console call in the file that was. docs/architecture.md ->
Console APIs already documented the invariant it broke: every one of
them is wrapped at its call site, because off an attached console the
setter throws SetValueInvocationException on Windows and the whole API
is unavailable elsewhere.

The write is reached only where the capture succeeded, so in practice
it ran on a real Windows console where it works. What it cost was the
failure mode that call exists to avoid: this runs inside the caller's
finally, so a throw there unwinds the one path that leaves the alt
buffer and shows the cursor again, stranding the terminal in exactly
the state the restore is for. The VT sequence above it is what the
cursor actually depends on; the API call is belt-and-suspenders for
the .NET-side state and not worth taking the restore down with it.
Command coverage went from 96.1% to 98.6% (2475 of 2509 instructions),
981 tests passing. The gaps closed are the ones that were untested
rather than untestable: Invoke-Main's whole action-dispatch switch and
the dot-source guard, save's snapshot and rollback diagnostics,
reconcile's degraded-input paths, the auth-verdict read and write
failures, the activator's timeout and subtype classification, the
load-time behavior with no resolvable home, and Test-ClaudeRunning,
which no test had ever executed.

The gate lands at 97 rather than 98, because coverage is measured on
one OS and the headroom above it is what the next platform-conditional
branch spends. Raising it to the number a run reaches would fail the
build for a branch that is tested, just not on this leg. The 34
instructions that remain are enumerated in docs/testing.md -> The
ceiling so the next person can tell a real gap from a structural one:
25 are Unix-only arms covered on the other legs, 5 need failures the
test host cannot provoke, and 4 are defense-in-depth arms reachable
only by mocking their immediate caller, which would pin the mock.

Common.ps1 mocks Test-ClaudeRunning for the whole suite and a mock
cannot be lifted, so the new file opts out through a flag read before
the dot-source and mocks Get-Process instead.
Dropping the refusal was right: Claude Code refreshes only when a request
needs it and serializes refreshes across processes behind a lock file, so
a warm pass cannot race a live client for a grant. Two things it left
open.

The first is sca's own loss, and it is the one no later pass repairs. The
round-robin overwrites .credentials.json once per slot, so every slot
depends on the reconcile after `claude -p` both running and capturing.
Three paths left that unchecked, each ending with a slot holding a
refresh token the server has already rotated:

  * The reconcile was sequential. Invoke-SlotActivator can throw after
    claude has already refreshed, reading its output files or reaching
    for its exit code, and the loop-body catch swallowed that without
    reconciling. It runs in a finally now.
  * Its result was never read. Invoke-Reconcile answers Captured = $false
    for identity-unresolved and credentials-changed-mid-probe, both of
    which saw changed bytes and deliberately wrote nothing. Its docblock
    makes reading the field mandatory and every other call site does.
  * The mirror writes through Set-CredentialFileAtomic, which throws.
    That proves nothing either way, so it is as unsafe to write over as
    an explicit refusal.

The pass stops on any of the three, and the restore in the finally is
skipped with it, because that restore is one more overwrite of exactly
the bytes the stop exists to protect. A swap failure still fails only its
own slot: that write is an atomic rename, so a throw leaves the file as
the previous slot's mirror captured it.

Naming the slot the user is left on could not have reached them before.
The restore-failure advisory was written as `Write-Color ... 6>$null`,
the only such line in the file, so it went to the information-stream sink
a test mock could still see and a user could not. Both watch call sites
suppress that same stream, so it rides back on the snapshot instead and
each caller renders it on the surface it owns.

The second is the user's. `sca warmup` warned about a live client and
then started immediately, which is a label rather than a decision, since
the first billable `claude -p` follows it by milliseconds. The warning
now precedes a five-second pause that makes the implied Ctrl-C reachable.
A prompt would be the stronger gate and was rejected: it needs a rule for
a redirected stdin, and this is a command people put in a scheduler.

`monitor -KeepWarm` removed the refusal without adding even the warning,
and it is the repeating one of the two. It cannot pause, so it carries
the same sentence in the footer latch, at startup and again at every
re-warm: a client opened at hour three of a watch is dragged across every
account by the very next pass.
The bar was documented as reaching 100% "only once the week has capped
every one of them", which is false and reads as a stronger signal than
the number carries. Get-PoolMeanUtilization averages the session
utilization of every slot still in the pool, so two slots at 5h = 100%,
7d = 50% read 100% with the week nowhere near capped. The all-capped case
is a second route to the same number, through the empty-pool early
return, not the only one.
MINOR rather than PATCH on two user-visible reversals this cycle carries:
the warm round-robin of `sca warmup` and `monitor -KeepWarm` now runs
beside an open Claude Code instead of refusing, and the `Session`
aggregate bar drops a week-capped slot from the pool entirely, so it
reports a different number wherever one exists.

The two Session-bar commits are folded into one entry. They are
successive refinements of the same rule, first scoring a capped slot at
100 and then dropping it, and only the net change is a fact about the
shipped version.
Write-Color took PowerShell color names, so any theme mapping
'DarkYellow' to blue would have turned every call site into a lie.
Rename the seven tokens to the semantic roles its docblock already
described (Heading, Warning, Success, Danger, Muted, Neutral), drop the
dead Cyan arm, and move the role -> SGR mapping out of the function into
$Script:ThemePalettes so a palette swap touches no call site.

$env:SCA_THEME picks a palette, resolved once per run in Invoke-Main.
Precedence is -NoColor > NO_COLOR > SCA_THEME > default: naming a theme
says which colors, not whether, and PlainText strips truecolor by the
same regex it already used for named SGR, so no-color mode needed no
theme-specific handling. An unknown name falls back quietly rather than
warning, because a typo lives in a shell profile and would otherwise
print on every invocation for as long as it sat there.

The default palette stays palette-relative, spelling roles as named ANSI
30-37/90-97 so it keeps following the user's own terminal scheme and
stays legible on any background; only an explicitly named theme burns in
truecolor. Nothing probes for truecolor support, because COLORTERM and
TERM are both unset in a Windows truecolor terminal and the probe would
answer wrong on the primary platform.

A theme may also declare Background + Foreground, applied only inside
the alternate screen that usage -Watch and monitor own. Scrollback is
excluded deliberately: a background there would leave ragged colored
bars in the user's shell history for good. Chrome is re-asserted after
every ESC[0m, since Write-Color's reset clears background along with
foreground, and asserted before each erase so those fill through
back_color_erase; repeated runs are collapsed so a 1 Hz repaint carries
no redundant bytes. Get-WatchChrome checks OutputRendering by hand
because Write-VTSequence bypasses the StringDecorated filter that gives
every other color path no-color mode for free.

Neutral stays absent from truecolor themes on purpose: it inherits the
chrome foreground inside a frame and the terminal's foreground outside
one, and a fixed hex would be wrong on a light or a dark background.
Each theme spelled out its own $PSStyle calls, so every theme added was a
fresh chance to wire a role differently from its siblings. State the
role-to-slot rule once in New-ThemePalette and reduce a theme to a row of
seven hex values in $Script:Base16Schemes, transcribed from the base16
definitions in tinted-theming/schemes. Adding a theme is now a data change
and the integrity tests pick it up with no edit of their own.

Ports dracula, everforest, flexoki, gruvbox, kanagawa, monokai, nord and
onedark alongside the existing material, whose values are unchanged.

github is deliberately absent despite having an upstream. base16 slots
carry syntax-highlighting meaning, which usually but not always coincides
with the ANSI meaning a status table needs: all three github dark variants
put orange in base08 and pale blue in base0B, so Danger would have
rendered orange and Success blue and a glance at the table would have
misread which slots were healthy. The suite now hue-checks both slots on
every scheme so a theme added later cannot reintroduce that.

Muted keeps base03 ("Comments") rather than base04 ("status bars"), even
though a status table is what it renders: base04 sits close enough to
base05 that the row stops reading as de-emphasized, and being dimmer than
the body text is the whole job. The resulting contrast runs 1.7:1 to 3.8:1
across these schemes, which is each theme's own comment contrast rather
than something to correct here.

Dark variants only. A light scheme is legible, the chrome path handles one
correctly, but shipping both doubles a list that is read at a glance.

The help screen wraps the names into the description gutter instead of
joining them onto one line, which had already reached 124 columns.
Ten palettes were named in `sca help` and the README and none of them was
shown, so choosing one meant setting SCA_THEME, re-running something and
repeating. Adds docs/themes.md built around a single rendered gallery, and
surfaces the same image in the README where the decision is made.

One combined SVG rather than one per theme. The four existing images run
368-370 KB apiece despite holding different amounts of text, so embedded
font data dominates and nine separate files would cost roughly 3.3 MB
against about 375 KB for one. Side by side is also what "see them all at
once" actually asks for.

The rows are generated by dot-sourcing switch_claude_account.ps1 and
looping its own $Script:Base16Schemes rather than transcribing the hexes a
second time, so the picture cannot drift from the palettes that ship. That
dot-source is read-only: the dispatcher is guarded by an InvocationName
check and nothing at load time touches a file. The hex table in
docs/themes.md is hand-maintained and was checked against the scheme data
across all nine themes and six columns.

`default` appears in the gallery with an explanation rather than being
omitted. It emits named ANSI and so renders in whatever the terminal's own
palette resolves those to, which no single image can be honest about; the
row is drawn in Campbell and the page says so.

Considered and rejected a `sca theme` action that would have previewed the
palettes and set the variable for the session. It works, because the
installed alias invokes the script with the call operator and so runs it in
the caller's process, but it would have added a tenth entry to a ValidateSet
otherwise entirely about credentials, plus a dispatch arm, a help row, a
test file and permanent maintenance, to serve a decision made once.
Every palette so far came from tinted-theming; this one has no upstream. It
is an original set in the same seven-slot shape, keyed to the warm accent and
near-black of the Claude Code interface this tool manages logins for, so the
tool can look like the thing it sits beside.

Its Danger is the one deliberate departure. The accent that makes the theme
recognizable sits at hue 15, and a true red at hue 0 would land within 15
degrees of the heading, close enough for the two to blur in a table that is
scanned rather than read. That is the same glance ambiguity that kept github
out. Danger is pulled to hue 349 instead, holding the pair 26 degrees apart
at a contrast of 3.6:1 against the background, which is where nord already
sits. Monokai is the precedent for a rose-leaning Danger reading correctly.

Muted lands at 3.1:1 over the canvas, better separated than material, nord or
onedark manage, so the footer stays legible without competing with the body.

No test changes: the scheme table drives the integrity tests, so the hue and
slot guards picked the new row up on their own.
The gallery showed a one-line swatch per theme, which answers "what colors
does this use" when the question a reader actually brings is "what will this
look like". Each theme now gets the entire monitor scene -- aggregate bars,
the five-row slot table, both footer lines -- painted on its own canvas, so
the page shows the view someone will sit in front of instead of a strip they
have to imagine applied.

Also removes a second, duplicate gallery block that 9697be1 shipped by
mistake. Both assigned $themeGalleryLines and the later one silently won, so
the committed image came from one block while the other's output was
discarded. It went unnoticed because that commit was verified by inspecting
the rendered SVG rather than re-reading the file around the insertion point.

The scene is now built by New-HeroLines from a role-to-SGR palette rather
than spelled out per palette, because it is rendered once in Campbell for
monitor.svg and again for each of the eleven themes; two copies of eighteen
hand-aligned columns would drift apart on the first edit. monitor.svg,
usage-watch.svg, usage-table.svg and usage-verbose.svg were hash-compared
before and after and are byte-identical, so the refactor changed nothing that
ships.

ConvertTo-ThemedPanel re-asserts the canvas after every ESC[0m, since a reset
drops the background along with the foreground and would otherwise punch a
hole to the end of each colored line -- the same rule
ConvertTo-WatchFrameSequence follows at runtime. Panel width is measured from
the scene rather than hardcoded, so widening a table column cannot leave the
canvas too narrow for it.

One image rather than eleven: the four existing SVGs run 368-370 KB apiece
because embedded font data dominates, so separate files would have cost
roughly 4 MB against 453 KB for one, and stacking them is what comparing
them asks for anyway.
The gallery was one tall image with each theme labelled inside it, which
left nothing to link to: a reader told "use nord" had to scroll and count.
Each theme now gets its own file and its own `## <name>` heading, so
docs/themes.md#nord goes straight there. The name moved out of the panel and
into the heading, since repeating it in both would just be two places to
keep in step.

Splitting one image into eleven would normally multiply its weight, and here
it did the opposite. Measured: a rendered SVG is 367 KB of which 970 bytes is
the drawing, all the rest being one base64 woff2 -- 99.7% font. The theme
panels drop the embedded face for a monospace fallback chain, which is safe
because they are column-aligned text and every viewer resolves the chain to
some monospace: a substituted face changes glyph shapes without disturbing
the alignment the scene depends on. Eleven panels cost 92 KB against the
453 KB the single combined image took, so docs/images/ fell from about
1.93 MB to 1.57 MB while going from one gallery to eleven.

The four README scenes keep their embedded copy. They are the front door,
there are only four of them, and pixel-identical rendering is worth 370 KB
there in a way it is not eleven times over. All four were hash-compared
before and after and are byte-identical.

docs/themes.md now carries each theme's hexes on its own line beneath its
heading rather than in one shared table, so a deep link lands on the image
and the values together. Verified against the scheme table: eleven headings,
eleven image references, eleven files on disk and sixty hex cells.
Two defects in the per-theme gallery, and the page trimmed to what it is
for.

The usage bars stopped filling their cells. 02114b4 dropped the embedded
font for a fallback chain on the grounds that the panels are column-aligned
text and any monospace would do. That was wrong: freeze emits no per-glyph
positions and no textLength, so the advance of every line comes from the
font, and a substituted face moves the text off the rect geometry freeze
computed from JetBrains Mono metrics. The block glyphs U+2588 and U+2593 are
where it shows first, because their job is to fill a cell exactly. Embedding
is restored and the rationale recorded so it is not traded away again;
correct rendering costs ~365 KB per image and the theme panels now total
4.1 MB.

The canvas only covered the text. Each row carried its own background SGR,
which left freeze's 30px window padding showing the default terminal black
around every panel, so a theme read as a rectangle floating on somebody
else's background. The color now goes to freeze's own --background, which
fills the entire window face. That also removes the per-row paint, the
pad-to-width that kept those rows flush, and the re-assert-after-reset dance
the background needed: 292 rects per panel become 2, and
ConvertTo-ThemedPanel is left doing only the foreground.

docs/themes.md is now a heading and an image per theme, nothing else. The
hex lines and per-theme prose repeated what the picture already says and
what switch_claude_account.ps1 already holds; the shared explanations move
below the gallery, and `default` keeps its caveat in the intro because no
image can be honest about a palette-relative theme.
The uncaptured-credentials abort breaks out of the round-robin, which left
every row behind it at the 'warming-up' the pass seeds them with. Two readers
take that for a result.

Invoke-KeepWarmStep counts any outcome that is not 'ok' as a failed warm, so
each untried slot was stamped with a cooldown and charged a failure, and
Get-WarmupCooldownMinutes doubles on that count: a slot the pass never entered
could be held off for up to 160 minutes, and only a successful warm clears the
counter that the inflated cooldown itself delays.

Invoke-WarmupAction then renders those rows through Get-UsageStatusLabel as
'warming up', in the attention-yellow Get-StatusColor gives the transients, in
the final table of a command that has already exited.

Give them a terminal 'skipped' instead, painted Muted because the abort
advisory beside the table is what the user has to read, and let the keep-warm
accounting leave a skipped slot exactly as it found it.
The repaint is the one statement in the round-robin's loop body that sits
outside a catch, and the watch startup pass is the caller that hands it a real
renderer writing to the console. A throw there unwound straight to the finally
with $uncaptured still false, so the restore ran and overwrote the credentials
nothing had captured: precisely the loss the abort exists to prevent, reached
through the guard's own blind spot.

Set $uncaptured, the advisory and the skipped rows immediately after the mirror
settles them, and leave the break after the repaint so the frame still shows the
row the pass stopped on.
The notice describes what the round-robin is about to cost, and the five-second
pause after it offers a window to call that off. Both ran before anything had
established that the pass had a slot to act on, so `sca warmup` on a fresh
install, or with a -Name matching nothing, warned about billing and blocked for
five seconds beside an open Claude Code before reporting that it had nothing to
activate. A false alarm on the only interlock the tool puts in front of money.

Split the pass's slot filter out as Get-WarmupSlotSet so the question can be
asked before the notice rather than only by the pass. The watch's equivalent
latch is cleared on the same discovery, where it had been left claiming a live
session would be dragged across accounts that do not exist.

Resolving Get-SafeName once in Invoke-WarmupAction falls out of the same change:
the name now reaches the preflight, the pass and the no-slots message already
sanitized, so a name that needs sanitizing is reported once instead of twice.
The 4.2.0 entry stopped at the warm-pass and aggregate-bar changes, but seven
commits after it added the whole SCA_THEME palette system: ten themes, the
themed alt-screen canvas, the rendered gallery and the help screen's new
ENVIRONMENT section. A user-facing Added with no release record, in an entry
that has never shipped (main is still 4.1.0 and no v4.2.0 tag exists), so it
folds in here rather than claiming a version of its own.

The image rule in AGENTS.md was one short for the same reason: the renderer
emits a panel for `default` as well as for every entry in $Script:Base16Schemes,
which is eleven files rather than ten.
Both Unix twins chmod a pre-existing slot pair to 000 so the snapshot read in
Invoke-SaveAction fails, then chmod it back in a finally. Mode 000 denies the
read but not the unlink, which the parent directory's permissions govern, so
the save they drive to a warning went on to delete that pair as an obsolete
sibling: the re-save carries a different email, the write lands on a new path,
and the old one is cleaned up behind it. The finally then handed
SetUnixFileMode a path that no longer existed and threw FileNotFoundException
before either assertion ran.

The Windows twins need no such guard because FileShare::None blocks the delete
along with the read. That is why this failed only on the Linux and macOS legs,
and why no Windows run could see it: both cases are -Skip:$IsWindows, and the
workflow's push trigger is main-only, so the branch reached a release pull
request before either leg had ever executed them.
@countzero
countzero merged commit 2015211 into main Sep 21, 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