Extra High MERGE AFTER FIX: source-scraping the setup file treated a
comment containing 'HERMES_HOME' as isolation. The lists now live in a
side-effect-free module that applyIsolation and the declaration test
both import. Comment-only sabotage fails with hermes:HERMES_HOME.
HERMES_HOME and eight sibling PROVIDER_ENV_VARS data-dir overrides were
fingerprinted for cache invalidation but never CLEARED by the vitest
setup file, so a Hermes-shell laptop parsed real sessions in fixtures.
A static guard fails closed when the map grows another undeclared home.
Maintainer review on #1050: drop metered codex-auto-review, add
kimi-for-coding-highspeed, match Warp's auto id, drop unsourced
big-pickle, and give model-flat-rate --remove power over built-ins.
The ported declaration was written against main as of c9e6e2ec. Main has since
added dsh and carries five entries that commit never saw, all of them still bare
tool roots, so the port dropped them rather than reintroduce what the store
rejected. Each is restored at the path its provider opens:
.dsh/sessions dsh.ts reads <DSH_HOME>/sessions only
.kiro/sessions CLI store at sessions/cli, v2 IDE store
at sessions/<hash> — siblings
.quickwork/{profiles.json,sessions,metrics}
profiles.json names the profile bases;
the legacy layout is sessions/sessions.db
plus metrics/
.config/Claude/local-agent-mode-sessions
Claude Desktop's local-agent-mode store
.config/Open Design/{runs,data/runs,namespaces}
the three discovery roots open-design.ts
probes under its data dir
.lingtai and .lingtai-tui stay dropped. A LingTai ledger lives at
.lingtai/<agent>/logs/token_ledger.jsonl, and personal-files has no wildcard for
the agent segment; .lingtai-tui only exists to enumerate project homes that could
not be read anyway. Goose is narrowed to .local/share/goose/sessions, which holds
the only file it opens.
Four entries stay whole roots because the provider reads a file sitting directly
in the root: .config/github-copilot (JetBrains stores nest under a variable
<ide>/<kind>/<storeId>) and .local/share/{opencode,crush,kilo}. The new
app/scripts/snap-grants.test.ts asserts every other entry is at least one level
below its tool root, so a bare root cannot come back unnoticed.
Snap Store review found the declaration requested each tool's entire root
directory. Those roots hold configuration and, in several cases, credentials,
and personal-files read is recursive, so the request granted read of every AI
tool's credential store.
Every path is now the subdirectory the provider actually opens: .claude/projects,
.codex/sessions, .cline/data, .vibe/logs/session, .hermes/profiles, .mux/sessions
and so on; two are single files (.forge/.forge.db, .zcode/cli/db/db.sqlite). The
editor entries name only the extension folders that hold transcripts rather than
the editor's whole configuration. No bare tool root remains.
One credential file is requested openly instead of implicitly: .claude/.credentials.json,
read-only, used to call Anthropic's usage endpoint for the live plan gauge.
Codex's equivalent needs read-write on the Codex CLI's auth.json to rotate the
token, so neither that file nor a Codex root is declared and the Codex live
gauge is disabled under $SNAP. Codex usage and cost analytics are unaffected;
they come from the session rollouts.
Also declares five providers that were missing entirely and would have shown no
data under the snap: opencode, crush, goose, kilo, kimi-code. Drops .lingtai,
whose per-agent directory sits above the log folder and cannot be expressed
without wildcards.
Ports 4fe760aafe0492a674476c1ca64bf0dbdc673dde onto current main; the entries
main gained since that commit are reconciled separately.
Maintainer review on #1049: empty-state copy under --provider codex
named detectors that scanSessions never ran. Say the session-scan
detectors do not cover that provider yet. Drop the dead TUI provider
threading; optimize view is still gated to all|claude.
Mirrors the pricing guard: CODEBURN_FX_NO_FETCH short-circuits getExchangeRate
after the cache read and before the fetch, returning the same USD-equivalent
rate an unreachable network already yields. Tests that seed their own
exchange-rate.json (serve-stdio's EUR case) keep working unchanged.
loadPricing() fetched the live LiteLLM table during tests and live data wins
over the bundled snapshot, so DeepSeek v4 assertions went red when upstream
dropped the off-peak discount. CODEBURN_PRICING_SNAPSHOT_ONLY skips the fetch;
env-isolation sets it for the whole suite.
#1040 fixed nested provenance.model. The compact Buffer path still
took the first cwd/session_id/originator/name anywhere in the payload.
Same depth-1 window for every session_meta string field. Bump parse
fingerprint and CODEX_CACHE_VERSION so present sources re-parse.
Extra High: calculateCost still told users to model-alias an
unknown SKU unconditionally. Route that warning through
unpricedModelHint so alias and model-flat-rate are both named.
The step is continue-on-error, but when it stalls it runs into the job's
15-minute budget and the whole job is reported cancelled, hiding the
parallel suite's result (#1040 three times in a row).
Extra High on #1049: keep the shipped Claude Code empty-state and
TUI header strings; resolve agent nouns from Provider.displayName;
cover all five cross-provider detector labels.
The test blocked on a DispatchSemaphore with a 15s deadline, commented as
running "on a real thread, not the cooperative pool". Swift Testing invokes
synchronous test bodies from a task on the cooperative pool, so the wait
parked one of activeProcessorCount workers on the very task group it was
waiting for. With 16 cores locally there is slack; on the 3-core macos-latest
runner, alongside the rest of the parallel suite, the group made no progress
at all and the wait expired.
Await the group directly instead, which also lets the compiler reject the
blocking wait (unavailable from async contexts), and bound the test with
.timeLimit rather than a hand-rolled wall clock. Assert each child came back
with a signal status, so the test now proves the timeout killed every hung
process instead of only that the group returned.
Reproduced by parking all but 3 cooperative threads for the run: 3/3 failures
at 15.0s before, 3/3 passes after. 10x full suite under CPU load: 160/160.
The Codex settings copy implied the cached credential was app-private. It is
a normal login-Keychain item: reachable by programs running as you, with no
per-app ACL. The real win is that it is no longer a world-readable 0644 file,
so say that instead.
Also documents why readAfterSecuringPermissions repairs permissions before
validating content (validating first would read the secret while it is still
world-readable, which is the window the function exists to close), and why the
Keychain service names are deliberately not derived from CFBundleIdentifier
(the Electron app hardcodes the same strings).
Adds the #1037 changelog entry.
disconnect() cleared the usage block before anyone knew whether the delete
had worked, and AppStore then returned early on failure — so a failed
disconnect cleared some state, left the rest, and still posted
subscriptionDisconnected. It also carried a second !isSuccess branch that the
early return had already made unreachable.
Both services now return the delete result and only clear the usage block on
success, so a failure changes nothing at all: the provider stays connected,
Disconnect stays available, and the banner asks for a retry. That matches the
success path's ordering instead of half-applying it.
Errors reaching the generic catches now render localizedDescription rather
than String(describing:), so a Keychain failure shows its message instead of
an enum dump with the raw item name in it.
Three fixes in the store read path.
A locked keychain no longer reads as a disconnect. currentRecord() treated
any failure from readOurCache() as fatal, and a nil as "the item vanished",
which cleared isBootstrapCompleted. .unavailable now falls back to the last
known record and leaves the flag set.
Recency. A Keychain hit always won and the legacy file was then unlinked,
even when the file was newer. This service name has been in use since May
2026, so an upgrading install can hold a months-old item beside a file the
pre-migration build wrote today; the older token won and the newer copy was
deleted. Both stores now compare first (expiresAt for Claude, lastRefresh for
Codex) and adopt the later one before anything is removed. Codex matters most
here: serving a spent rotating refresh token ends in a terminal invalid_grant.
lastLegacyCleanupFailed is gone. It was set on every cleanup path and read
only by tests, never surfaced. The retry it was meant to signal already
happens, because the unlink is attempted on every successful read.
Also serializes migrate + unlink under the existing SafeFile.withExclusiveLock
so two menubar instances cannot race on the same legacy file, and drops a
leftover no-op local.
Cache reads run on the background quota timer, so they must not be able to
put a panel on screen. Measured on macOS 15 against a throwaway keychain:
with the keychain locked, SecItemCopyMatching blocks on an unlock panel even
when the query carries kSecUseAuthenticationUI: ...Fail or a non-interactive
LAContext. Both of those govern the data-protection keychain; unlocking a
file-based keychain is something securityd drives itself. The only reliable
suppression is not issuing the read, so check lock state first and report
.unavailable instead.
.unavailable is separate from readFailed on purpose: a locked keychain means
"cannot look right now", not "the item is gone", and callers must not turn it
into a disconnect. It also carries a readable errorDescription so a -25308
reaching the UI reads as "Keychain unavailable" rather than a struct dump.
SecKeychainGetStatus is soft-deprecated with no replacement that reports
file-keychain lock state; annotating the warning away only moves it to the
call site, so it is left visible with a comment.
Adds the first test that touches a real Keychain, against a throwaway service
name no build reads, skipped when the host has no usable Keychain.
It was the only MiMo row still rendering as its raw slug next to
"MiMo v2.5" and "MiMo v2.5 Pro". SORTED_SHORT_NAMES is longest-first, so
the two v2.5 entries keep their own labels.
githubOwnerRepoFromRoot re-read .git/config once per session, so every
session in the same repo paid for the same two syscalls. Memoize it per repo
root for the life of the process.
The prLinks plumbing from a provider call through the session cache into the
session summary had no test above the provider boundary; add one that runs
the real parseAllSessions pipeline against a temp HERMES_HOME. It fails
against the pre-change parser.
The hand-written KNOWN_NAMESPACES set dropped pricing for vendor prefixes
LiteLLM itself indexes: x-ai/, nousresearch/, zhipu/, litellm_proxy/ and
openai_like/ all priced on main and went unpriced here. Derive the set from
the loaded pricing keys instead, so a vendor the catalog knows is never lost
to a stale list, and keep only the spellings no catalog carries as explicit
extras: the routing wrappers, the client-side kimi/ and mimo/ prefixes, and
the litellm_proxy/ + openai_like/ routes. Local runners are excluded on
purpose, so an unlisted ollama tag cannot strip down to a priced cloud row.
xiaomi/ stops being a routing wrapper: it is the namespace LiteLLM prices
MiMo under, and BUILTIN_ALIASES maps the bare MiMo ids INTO it, so peeling
pulled against the alias. It stays known via the derived set.
Also: a user price override for a bare id now wins over the catalog row a
routed spelling of it would otherwise hit, and a namespaced GLM-5.3 is no
longer LABELLED GLM-5.2 by the sibling alias it prices through.
Tests assert the allowlist through a price override on a synthetic id, so
they cannot rot with the snapshot; the glm-5.4 assertion that pinned on the
snapshot NOT carrying a model is dropped.
The budget window comes from computePeriodFromResetDay, which builds an
anniversary period from plan.resetDay (1-28, settable per plan with
`codeburn plan set --reset-day`). "Calendar-month budget" and "Next
calendar reset" are therefore wrong for anyone who moved the reset day,
which is the same class of inaccuracy this change set exists to remove.
Say "budget" and "Next budget reset" instead, and use one wording across
the TUI and the desktop cards.
Both TUI lines truncate end-first at the terminal width. The headline had
grown past the point where an 80-column terminal still showed the
percentage, so it drops "vs ... /mo" for "/ $300.00 budget", and the
status line drops the clause repeating "budget" from the headline. At 80
columns the longest label (custom plans carry their provider) now fits
the percentage, and the status line still shows the projection.
The codex parse version and CODEX_CACHE_VERSION bumps in #1040 make codex
sessions re-parse, but the daily cache has no per-provider invalidation, so
every day already finalized keeps its old per-model rows - and usage-aggregator
serves every day before today from that cache, with ten-year retention. Raise
DAILY_CACHE_VERSION and MIN_SUPPORTED_VERSION to 20 so history re-derives once
off the warm session cache.
The re-derivation test now seeds v19, the last shipped version, so it models the
real 19 -> 20 path, and the upgrade-path check expects daily-cache.v20.json.
Measured on a real 110-day cache: no day lost value, none disappeared, 100 came
back identical, and 9 grok days rose by $19.80 in total from rollups an earlier
parse change had left stale. Every codex model row was unchanged - that corpus
predates the provenance field the fix corrects.
The `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias shipped before this
branch and already cycled through display-name resolution, so
getShortModelName threw RangeError on every real MiMo v2 Flash session.
The new cycle-safe resolver fixes it, but nothing pinned the ids that
actually crashed in production: cover the four spellings found in a real
session cache, including the unnamespaced `mimo/mimo-v2-flash`.
Add the base `mimo-v2.5` display name so the row reads next to
"MiMo v2.5 Pro" instead of showing a raw slug; SORTED_SHORT_NAMES is
longest-first, so the Pro tier still wins its own entry.