Repository navigation
fix: split the Claude Keychain read so it stops failing with errSecParam (-50) - #243
Merged
Merged
Conversation
kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that pair with errSecParam (-50) during parameter validation, before it looks for any item. The query added in #232 therefore never succeeded for anyone whose credentials live only in the Keychain — an approved ACL, a re-login, or a single matching item made no difference. Measured on a real install: 24 consecutive -50 failures, zero successes. Enumerate accounts with attributes only, then read each candidate's data with kSecMatchLimitOne, preserving #232's intent of picking the entry that carries claudeAiOauth. Falls back to an unscoped single read when no account attribute comes back. The old tests exercised extractDataItems with synthetic arrays, so SecItemCopyMatching was never on the test path and the defect sat one layer above what they covered. The new guard aims the production queries at a service name that cannot exist -- nothing matches, so no ACL or prompt is involved -- and asserts the framework does not answer errSecParam. extractDataItems is removed along with its tests; it has no production caller left.
cte13
added a commit
to cte13/PokeTokenBar-iOS
that referenced
this pull request
Aug 31, 2026
* fix: prevent invisible text in floating pet hover tooltip (chattymin#193) * fix: pick up in-place Claude account switch on auto-poll (chattymin#227) (chattymin#228) The OAuth cache returned a still-unexpired token without re-reading credentials.json, so /login to a second Team email left the 5h bars on the previous account. Peek the file before the cache hit; Keychain stays off the auto path. Same class in the Antigravity token cache. * fix: resolve Claude account OAuth across multiple Keychain entries (chattymin#232) * fix: show the Raising badge only on the current evolution stage (chattymin#211) * fix: mark only current evolution stage as raising * docs: clarify Pokédex raising badge semantics * docs: correct Raising badge width note * feat: let users pick animation quality for the menu bar sprite and floating pet (chattymin#212) * fix: cap the sprite frame rate by decimating frames, not stretching them The always-visible surfaces (menu bar sprite, floating pet) capped their frame rate by raising each frame's delay -- max(floor, delay). That keeps the frame count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s = 2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale ("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate; it missed that playback slowed 8x. Two-frame bob animations hid the defect, because holding and decimating are the same thing there. GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated floor-length span, so the loop length matches the original and only the frame rate drops. Same wakeup budget, correct speed, and fewer frames to composite (55 -> 6 at a 0.4s floor). Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to the popover, which runs with tolerance .zero, that made the menu bar look slower on its own. The menu bar cap also moves into a named constant. It was an inline max(0.4, delay) while the pet's cap was a tested constant, so the cap could vanish from the menu bar without any test noticing; both surfaces are now asserted together in testAlwaysVisibleSurfacesAreCapped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: let users pick animation quality for the menu bar sprite and floating pet The frame-rate cap on the always-visible surfaces was a single hardcoded value, and no single value fits: the per-frame cost is a status-bar recomposite, so how smooth it looks and how much battery it costs both vary by device and by sprite. A 22px menu bar hides the low frame rate that is clearly visible on a 96px floating pet. This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s / ~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI languages. No preset can disable the cap; 0 (native fps) is not reachable from the UI, which keeps the idle-wakeup guarantee that the cap exists. The default is Power saver: it is the same frame rate the app used before this setting existed, so an existing user's battery profile does not change and the smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced, 5.1% at Smooth. The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey, SpriteView.frameTaskID) because cached frames are already decimated to a specific rate -- without it, changing the setting would not take effect until the next evolution. The menu bar also observes the setting explicitly: observeStore only tracks menuTitle, so otherwise a change would not show until the next usage poll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cursor): read usage from dashboard API when local tokenCount is zero (chattymin#197) * feat(cursor): fetch usage from dashboard API when local tokenCount is zero Cursor's state.vscdb bubbles often store 0/0 token counts since early 2026, so the SQLite scan under-reports. When the user is signed into Cursor, read the same unofficial dashboard export the web UI uses (with sub::jwt cookie auth from cursorAuth/accessToken), fall back to local bubbles or Downloads CSV exports, and avoid merging dashboard rows with stale bubble estimates. API and in-memory cursor reload cache TTL follow Settings → refresh interval (1/2/5/15 min or manual = refetch on every refresh()). Closes chattymin#196 * refactor(cursor): remove CSV export and local file fallbacks Dashboard JSON API is the sole authoritative source when signed in; keep SQLite bubble scan only as offline fallback. * fix(cursor): satisfy CI env guard and ISO8601 date parsing in tests Route CURSOR_USAGE_API through UsageEnvironment and reuse CompanionStore state dir for the API cache path. Parse fractional-second ISO dates in CursorUsageTests. * fix(cursor): extract AppStatePaths for nonisolated cache directory CompanionStore.defaultURL() is MainActor-isolated; share PTB_STATE_DIR resolution via AppStatePaths for Cursor API disk cache. * fix(cursor): harden dashboard usage fallback Keep cached usage account-scoped, reject incomplete pagination, preserve authoritative empty responses, and make paginated event IDs unique. * fix(cursor): harden API fetch, cache, logging, and pagination tests Add explicit fetch failure reasons and HTTP status logging, ephemeral URLSession, request/page deadlines, account-scoped cache TTL and coveredSince checks, safer pagination/id handling, and SQLite fallback when stale cache is non-authoritative. * test(cursor): fix transport test closures for CI Avoid throwing closures in injected transport and compare empty fetch results by count. * test(cursor): use Sendable page recorder in pagination test Fix Swift 6 concurrency error when capturing mutable state in injected transport. * fix(cursor): accept legacy disk cache without accountIdentifier Migrate pre-account-scoped cache files instead of ignoring them on upgrade. * fix(cursor): send epoch-ms range and read usageEventsDisplay The dashboard endpoint was never exercised while the CSV export was the primary source, so two request/response mismatches stayed hidden until CSV was removed: - startDate/endDate were sent as ISO8601, which the endpoint answers with HTTP 500. It expects epoch milliseconds as strings. - Events are returned under usageEventsDisplay, not usageEvents/events, so parsing failed even once the request succeeded. Pagination now also uses totalUsageEventsCount when the response carries no pagination object. Verified live: 239 events fetched. * docs(cursor): sync ko/ja READMEs with dashboard API data source The English README gained the cursor.com data source row and the updated Privacy host list, but the Korean and Japanese translations still listed state.vscdb as Cursor's only source and claimed seven outbound hosts. CLAUDE.md requires all three READMEs to stay in sync. Also notes that the offline disk cache is account-scoped and capped at 6 h, so readers know how stale a fallback reading can be. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: read Kiro CLI 2.20+ JSONL sessions (chattymin#236) (chattymin#238) * fix: read Kiro CLI 2.20+ JSONL sessions instead of only data.sqlite3 Kiro stopped writing new turns to the SQLite store. Scan ~/.kiro/sessions (cli/*.jsonl and v3 messages.jsonl) while keeping the legacy database, and register KIRO_HOME / KIRO_CLI_HOME so the GUI app can see those overrides. * fix: keep every v3 Kiro turn when timestamps are missing Flat messages.jsonl lines share session.json createdAt, so millis ids collapsed under dedupKeepMax. Key v3 entries by turn index and count tool_call args as output, matching the writer-shaped parser. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Add oh-my-pi (omp) usage provider (chattymin#214) * Add oh-my-pi (omp) usage provider Parse pi-format session JSONL under ~/.omp/agent/sessions recursively: every assistant message's usage event is summed (rewound branches are already billed; subagent sessions count since their tokens are not folded into the parent), plus compaction/branch_summary envelope usage, mirroring parsePiFile's semantics. Per-event cost.total is trusted over the pricing table when reported; OMP_CODING_AGENT_DIR overrides the agent home (verified against the omp binary's env vars). bridge/ conversion copies are excluded: they mirror sessions whose usage is already counted at the original source (verified against a real bridge file duplicating a -Projects/ session). Also make testHermesAcceptsMillisecondStartedAt timezone-independent (it hardcoded a local day that only holds at UTC+0/east) and cover the previously uncovered omp reader branches. * Use omp as the provider display name The tool ships as `omp` (omp.sh, the `omp` binary, Homebrew `can1357/tap/omp`), so the provider tab and Settings now show that name instead of `OMP`. The README tables keep the repository name alongside it as `omp (oh-my-pi)` for discovery. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Update readme (chattymin#239) * Add Trendshift badges to README * Update Korean README with Trendshift badges * Update README.ja.md * feat: fetch official limits with a claude.ai session key (no Keychain prompt) (chattymin#241) * feat: fetch official limits with a claude.ai session key (no Keychain prompt) Auto-polling never reads the Keychain by design, so once the cached token expires and ~/.claude/.credentials.json is absent, official limits freeze until the user presses "Refresh limit token cache" -- and that press raises the password dialog on unnotarised builds, where an "Always Allow" grant does not survive the next code-signature change. This adds an opt-in second credential path. Paste a claude.ai sessionKey cookie under Settings -> Advanced and limits come from claude.ai/api/organizations/{org}/usage, which returns the same schema as api.anthropic.com/api/oauth/usage -- so LimitStatus is reused unchanged and no popover row changes shape. The path touches no Keychain, so auto-polling keeps limits current and there is no ACL grant to lose on the next update. Without a key nothing changes: ChainedLimitsProvider falls through to the existing OAuth path and no file is created. Organization selection is not "the first one". A claude.ai account also returns orgs the user merely logged into, and API-only orgs that answer 403, so picking first yields a permanent and plausible-looking 0%. Candidates are filtered to chat-capable orgs, probed in parallel, and the one with usage is selected and cached. A cached org that later returns 403 triggers rediscovery. A dead key answers 403, not 401 -- verified by flipping one character of a stored key in a running build, where the usage endpoint and /api/organizations both returned 403. Since /api/organizations is not organization-scoped, a 403 there means the key itself can see nothing, so it maps to sessionKeyInvalid. Per-organization 403s keep their existing meaning and still trigger rediscovery. Expiry carries its source (limitsAuthExpiry: .oauth | .sessionKey) rather than a single boolean, because the two remedies are opposites: a session key must be re-copied from the browser, while OAuth only needs a Keychain re-read. Collapsed into one flag, a session-key user is told to run Claude Code once -- which does nothing for a dead cookie -- and the retry button reads the Keychain, raising the very prompt the session key exists to avoid. The settings badge reads "Expired" while the dead key is still stored, and the banner opens Settings with the Advanced disclosure already expanded, since the key field lives inside it. The key is stored as an owner-only (0600) plaintext file, not in the Keychain: an app-owned Keychain item is the very class of defect this removes, because a cdhash change invalidates its ACL and the prompts return. The settings copy states the storage model and that a browser logout invalidates the key. Note for anyone testing this: curl cannot verify these endpoints. Cloudflare answers it with its own 403 challenge regardless of the cookie, so it will confirm any theory brought to it. Only URLSession gives a real answer; the unit tests seam out the HTTP layer instead. Refs chattymin#240 * docs: list claude.ai in the outbound-host section (en/ko/ja) The session-key path adds a twelfth outbound host, and the privacy section enumerates every host the app talks to. * fix: keep a configured session key from falling back into the Keychain A stored-but-rejected key used to pass allowKeychainPrompt straight through to the OAuth fallback, so pressing Refresh raised the very password dialog the key exists to avoid. The remedy for a dead key is re-pasting it, not the Keychain, and the expiry banner already routes there. Users with no key keep today's behaviour, which the second test locks. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: keep released Pokémon in the Pokédex (chattymin#242) Buying an egg discarded the active Pokémon without touching `dex`, so a species that was only backed by that companion vanished from the Pokédex. That was the one path where the collection screen could shrink, which contradicts the promise it makes. `buyEgg` now records the released companion as a `DexEntry` carrying `releasedAt`. The Pokédex folds `state.dex`, so species retention follows automatically; only the capture log distinguishes the two, with a `Released` badge next to the existing `Raising` one. Three rules the record follows: - Only reached forms are credited (`pathIDs.prefix(stageIndex + 1)`). Using `plannedPathIDs` would make buying an egg a shortcut to filling the Pokédex with evolutions never reached. - Shiny follows `currentIsShiny`, so releasing a disguised Ditto does not reveal it early. - `collectedFinals` is untouched — a released companion was not raised to its final form, so it must not count toward completion or branch weighting. `releasedAt == nil` means "graduated", so existing saves need no migration. A representative pinned to a released species now survives the release, because the species is still owned. * feat: add German UI language (chattymin#234) Add native German copy, official Pokémon terminology, locale detection, and regression coverage so German users get a complete localized experience. Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: split the Claude Keychain read so it stops failing with errSecParam (chattymin#243) kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that pair with errSecParam (-50) during parameter validation, before it looks for any item. The query added in chattymin#232 therefore never succeeded for anyone whose credentials live only in the Keychain — an approved ACL, a re-login, or a single matching item made no difference. Measured on a real install: 24 consecutive -50 failures, zero successes. Enumerate accounts with attributes only, then read each candidate's data with kSecMatchLimitOne, preserving chattymin#232's intent of picking the entry that carries claudeAiOauth. Falls back to an unscoped single read when no account attribute comes back. The old tests exercised extractDataItems with synthetic arrays, so SecItemCopyMatching was never on the test path and the defect sat one layer above what they covered. The new guard aims the production queries at a service name that cannot exist -- nothing matches, so no ACL or prompt is involved -- and asserts the framework does not answer errSecParam. extractDataItems is removed along with its tests; it has no production caller left. * docs: record why Claude's refreshToken must not be used for renewal (chattymin#247) The Keychain credential carries refreshToken and refreshTokenExpiresAt next to a ~5h accessToken, which makes "renew with it and stop touching the Keychain every few hours" an obvious idea. It is the wrong one: Claude Code's refresh handler replaces its stored refreshToken with the value from the response, so the token rotates. An external app renewing on its own would leave Claude Code holding a dead token and force a re-login, and the only way around that is writing into someone else's credential store, which the app-owned-Keychain rule already forbids. Antigravity can do this because Google does not rotate — its cache reuses the same refreshToken across renewals. The asymmetry is the providers' token semantics, not a gap on the Claude side. Rotation is inferred from the replace-on-store behaviour rather than tested; the cost of being wrong is breaking the user's primary tool, so the experiment was not run. The decision rests on the asymmetry instead: at best it is a weaker version of what the session key already provides, at worst Claude Code breaks. Also records that adding a silent-first Keychain read on the user-action path buys nothing, so the Antigravity pattern is not worth porting there either. --------- Co-authored-by: Seong SeungHyeon <73338585+ite476@users.noreply.github.com> Co-authored-by: Boris <148207123+Borisserz@users.noreply.github.com> Co-authored-by: Justin Jeong <justin.jeong@buzzvil.com> Co-authored-by: 개발민수 <minsoo010@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6118@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Florian Behrend <56365738+florianbehrend@users.noreply.github.com> Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> Co-authored-by: Igor Borges <igor@borges.dev> Co-authored-by: Patrick.Park <pdm001125@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6120@naver.com> Co-authored-by: Manuel <97159984+mledutke@users.noreply.github.com> Co-authored-by: Cory Eurom <ce@Corys-MacBook-Pro.local>
cte13
added a commit
to cte13/PokeTokenBar-iOS
that referenced
this pull request
Sep 2, 2026
* fix: prevent invisible text in floating pet hover tooltip (chattymin#193) * fix: pick up in-place Claude account switch on auto-poll (chattymin#227) (chattymin#228) The OAuth cache returned a still-unexpired token without re-reading credentials.json, so /login to a second Team email left the 5h bars on the previous account. Peek the file before the cache hit; Keychain stays off the auto path. Same class in the Antigravity token cache. * fix: resolve Claude account OAuth across multiple Keychain entries (chattymin#232) * fix: show the Raising badge only on the current evolution stage (chattymin#211) * fix: mark only current evolution stage as raising * docs: clarify Pokédex raising badge semantics * docs: correct Raising badge width note * feat: let users pick animation quality for the menu bar sprite and floating pet (chattymin#212) * fix: cap the sprite frame rate by decimating frames, not stretching them The always-visible surfaces (menu bar sprite, floating pet) capped their frame rate by raising each frame's delay -- max(floor, delay). That keeps the frame count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s = 2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale ("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate; it missed that playback slowed 8x. Two-frame bob animations hid the defect, because holding and decimating are the same thing there. GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated floor-length span, so the loop length matches the original and only the frame rate drops. Same wakeup budget, correct speed, and fewer frames to composite (55 -> 6 at a 0.4s floor). Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to the popover, which runs with tolerance .zero, that made the menu bar look slower on its own. The menu bar cap also moves into a named constant. It was an inline max(0.4, delay) while the pet's cap was a tested constant, so the cap could vanish from the menu bar without any test noticing; both surfaces are now asserted together in testAlwaysVisibleSurfacesAreCapped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: let users pick animation quality for the menu bar sprite and floating pet The frame-rate cap on the always-visible surfaces was a single hardcoded value, and no single value fits: the per-frame cost is a status-bar recomposite, so how smooth it looks and how much battery it costs both vary by device and by sprite. A 22px menu bar hides the low frame rate that is clearly visible on a 96px floating pet. This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s / ~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI languages. No preset can disable the cap; 0 (native fps) is not reachable from the UI, which keeps the idle-wakeup guarantee that the cap exists. The default is Power saver: it is the same frame rate the app used before this setting existed, so an existing user's battery profile does not change and the smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced, 5.1% at Smooth. The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey, SpriteView.frameTaskID) because cached frames are already decimated to a specific rate -- without it, changing the setting would not take effect until the next evolution. The menu bar also observes the setting explicitly: observeStore only tracks menuTitle, so otherwise a change would not show until the next usage poll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cursor): read usage from dashboard API when local tokenCount is zero (chattymin#197) * feat(cursor): fetch usage from dashboard API when local tokenCount is zero Cursor's state.vscdb bubbles often store 0/0 token counts since early 2026, so the SQLite scan under-reports. When the user is signed into Cursor, read the same unofficial dashboard export the web UI uses (with sub::jwt cookie auth from cursorAuth/accessToken), fall back to local bubbles or Downloads CSV exports, and avoid merging dashboard rows with stale bubble estimates. API and in-memory cursor reload cache TTL follow Settings → refresh interval (1/2/5/15 min or manual = refetch on every refresh()). Closes chattymin#196 * refactor(cursor): remove CSV export and local file fallbacks Dashboard JSON API is the sole authoritative source when signed in; keep SQLite bubble scan only as offline fallback. * fix(cursor): satisfy CI env guard and ISO8601 date parsing in tests Route CURSOR_USAGE_API through UsageEnvironment and reuse CompanionStore state dir for the API cache path. Parse fractional-second ISO dates in CursorUsageTests. * fix(cursor): extract AppStatePaths for nonisolated cache directory CompanionStore.defaultURL() is MainActor-isolated; share PTB_STATE_DIR resolution via AppStatePaths for Cursor API disk cache. * fix(cursor): harden dashboard usage fallback Keep cached usage account-scoped, reject incomplete pagination, preserve authoritative empty responses, and make paginated event IDs unique. * fix(cursor): harden API fetch, cache, logging, and pagination tests Add explicit fetch failure reasons and HTTP status logging, ephemeral URLSession, request/page deadlines, account-scoped cache TTL and coveredSince checks, safer pagination/id handling, and SQLite fallback when stale cache is non-authoritative. * test(cursor): fix transport test closures for CI Avoid throwing closures in injected transport and compare empty fetch results by count. * test(cursor): use Sendable page recorder in pagination test Fix Swift 6 concurrency error when capturing mutable state in injected transport. * fix(cursor): accept legacy disk cache without accountIdentifier Migrate pre-account-scoped cache files instead of ignoring them on upgrade. * fix(cursor): send epoch-ms range and read usageEventsDisplay The dashboard endpoint was never exercised while the CSV export was the primary source, so two request/response mismatches stayed hidden until CSV was removed: - startDate/endDate were sent as ISO8601, which the endpoint answers with HTTP 500. It expects epoch milliseconds as strings. - Events are returned under usageEventsDisplay, not usageEvents/events, so parsing failed even once the request succeeded. Pagination now also uses totalUsageEventsCount when the response carries no pagination object. Verified live: 239 events fetched. * docs(cursor): sync ko/ja READMEs with dashboard API data source The English README gained the cursor.com data source row and the updated Privacy host list, but the Korean and Japanese translations still listed state.vscdb as Cursor's only source and claimed seven outbound hosts. CLAUDE.md requires all three READMEs to stay in sync. Also notes that the offline disk cache is account-scoped and capped at 6 h, so readers know how stale a fallback reading can be. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: read Kiro CLI 2.20+ JSONL sessions (chattymin#236) (chattymin#238) * fix: read Kiro CLI 2.20+ JSONL sessions instead of only data.sqlite3 Kiro stopped writing new turns to the SQLite store. Scan ~/.kiro/sessions (cli/*.jsonl and v3 messages.jsonl) while keeping the legacy database, and register KIRO_HOME / KIRO_CLI_HOME so the GUI app can see those overrides. * fix: keep every v3 Kiro turn when timestamps are missing Flat messages.jsonl lines share session.json createdAt, so millis ids collapsed under dedupKeepMax. Key v3 entries by turn index and count tool_call args as output, matching the writer-shaped parser. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Add oh-my-pi (omp) usage provider (chattymin#214) * Add oh-my-pi (omp) usage provider Parse pi-format session JSONL under ~/.omp/agent/sessions recursively: every assistant message's usage event is summed (rewound branches are already billed; subagent sessions count since their tokens are not folded into the parent), plus compaction/branch_summary envelope usage, mirroring parsePiFile's semantics. Per-event cost.total is trusted over the pricing table when reported; OMP_CODING_AGENT_DIR overrides the agent home (verified against the omp binary's env vars). bridge/ conversion copies are excluded: they mirror sessions whose usage is already counted at the original source (verified against a real bridge file duplicating a -Projects/ session). Also make testHermesAcceptsMillisecondStartedAt timezone-independent (it hardcoded a local day that only holds at UTC+0/east) and cover the previously uncovered omp reader branches. * Use omp as the provider display name The tool ships as `omp` (omp.sh, the `omp` binary, Homebrew `can1357/tap/omp`), so the provider tab and Settings now show that name instead of `OMP`. The README tables keep the repository name alongside it as `omp (oh-my-pi)` for discovery. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Update readme (chattymin#239) * Add Trendshift badges to README * Update Korean README with Trendshift badges * Update README.ja.md * feat: fetch official limits with a claude.ai session key (no Keychain prompt) (chattymin#241) * feat: fetch official limits with a claude.ai session key (no Keychain prompt) Auto-polling never reads the Keychain by design, so once the cached token expires and ~/.claude/.credentials.json is absent, official limits freeze until the user presses "Refresh limit token cache" -- and that press raises the password dialog on unnotarised builds, where an "Always Allow" grant does not survive the next code-signature change. This adds an opt-in second credential path. Paste a claude.ai sessionKey cookie under Settings -> Advanced and limits come from claude.ai/api/organizations/{org}/usage, which returns the same schema as api.anthropic.com/api/oauth/usage -- so LimitStatus is reused unchanged and no popover row changes shape. The path touches no Keychain, so auto-polling keeps limits current and there is no ACL grant to lose on the next update. Without a key nothing changes: ChainedLimitsProvider falls through to the existing OAuth path and no file is created. Organization selection is not "the first one". A claude.ai account also returns orgs the user merely logged into, and API-only orgs that answer 403, so picking first yields a permanent and plausible-looking 0%. Candidates are filtered to chat-capable orgs, probed in parallel, and the one with usage is selected and cached. A cached org that later returns 403 triggers rediscovery. A dead key answers 403, not 401 -- verified by flipping one character of a stored key in a running build, where the usage endpoint and /api/organizations both returned 403. Since /api/organizations is not organization-scoped, a 403 there means the key itself can see nothing, so it maps to sessionKeyInvalid. Per-organization 403s keep their existing meaning and still trigger rediscovery. Expiry carries its source (limitsAuthExpiry: .oauth | .sessionKey) rather than a single boolean, because the two remedies are opposites: a session key must be re-copied from the browser, while OAuth only needs a Keychain re-read. Collapsed into one flag, a session-key user is told to run Claude Code once -- which does nothing for a dead cookie -- and the retry button reads the Keychain, raising the very prompt the session key exists to avoid. The settings badge reads "Expired" while the dead key is still stored, and the banner opens Settings with the Advanced disclosure already expanded, since the key field lives inside it. The key is stored as an owner-only (0600) plaintext file, not in the Keychain: an app-owned Keychain item is the very class of defect this removes, because a cdhash change invalidates its ACL and the prompts return. The settings copy states the storage model and that a browser logout invalidates the key. Note for anyone testing this: curl cannot verify these endpoints. Cloudflare answers it with its own 403 challenge regardless of the cookie, so it will confirm any theory brought to it. Only URLSession gives a real answer; the unit tests seam out the HTTP layer instead. Refs chattymin#240 * docs: list claude.ai in the outbound-host section (en/ko/ja) The session-key path adds a twelfth outbound host, and the privacy section enumerates every host the app talks to. * fix: keep a configured session key from falling back into the Keychain A stored-but-rejected key used to pass allowKeychainPrompt straight through to the OAuth fallback, so pressing Refresh raised the very password dialog the key exists to avoid. The remedy for a dead key is re-pasting it, not the Keychain, and the expiry banner already routes there. Users with no key keep today's behaviour, which the second test locks. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: keep released Pokémon in the Pokédex (chattymin#242) Buying an egg discarded the active Pokémon without touching `dex`, so a species that was only backed by that companion vanished from the Pokédex. That was the one path where the collection screen could shrink, which contradicts the promise it makes. `buyEgg` now records the released companion as a `DexEntry` carrying `releasedAt`. The Pokédex folds `state.dex`, so species retention follows automatically; only the capture log distinguishes the two, with a `Released` badge next to the existing `Raising` one. Three rules the record follows: - Only reached forms are credited (`pathIDs.prefix(stageIndex + 1)`). Using `plannedPathIDs` would make buying an egg a shortcut to filling the Pokédex with evolutions never reached. - Shiny follows `currentIsShiny`, so releasing a disguised Ditto does not reveal it early. - `collectedFinals` is untouched — a released companion was not raised to its final form, so it must not count toward completion or branch weighting. `releasedAt == nil` means "graduated", so existing saves need no migration. A representative pinned to a released species now survives the release, because the species is still owned. * feat: add German UI language (chattymin#234) Add native German copy, official Pokémon terminology, locale detection, and regression coverage so German users get a complete localized experience. Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: split the Claude Keychain read so it stops failing with errSecParam (chattymin#243) kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that pair with errSecParam (-50) during parameter validation, before it looks for any item. The query added in chattymin#232 therefore never succeeded for anyone whose credentials live only in the Keychain — an approved ACL, a re-login, or a single matching item made no difference. Measured on a real install: 24 consecutive -50 failures, zero successes. Enumerate accounts with attributes only, then read each candidate's data with kSecMatchLimitOne, preserving chattymin#232's intent of picking the entry that carries claudeAiOauth. Falls back to an unscoped single read when no account attribute comes back. The old tests exercised extractDataItems with synthetic arrays, so SecItemCopyMatching was never on the test path and the defect sat one layer above what they covered. The new guard aims the production queries at a service name that cannot exist -- nothing matches, so no ACL or prompt is involved -- and asserts the framework does not answer errSecParam. extractDataItems is removed along with its tests; it has no production caller left. * docs: record why Claude's refreshToken must not be used for renewal (chattymin#247) The Keychain credential carries refreshToken and refreshTokenExpiresAt next to a ~5h accessToken, which makes "renew with it and stop touching the Keychain every few hours" an obvious idea. It is the wrong one: Claude Code's refresh handler replaces its stored refreshToken with the value from the response, so the token rotates. An external app renewing on its own would leave Claude Code holding a dead token and force a re-login, and the only way around that is writing into someone else's credential store, which the app-owned-Keychain rule already forbids. Antigravity can do this because Google does not rotate — its cache reuses the same refreshToken across renewals. The asymmetry is the providers' token semantics, not a gap on the Claude side. Rotation is inferred from the replace-on-store behaviour rather than tested; the cost of being wrong is breaking the user's primary tool, so the experiment was not run. The decision rests on the asymmetry instead: at best it is a weaker version of what the session key already provides, at worst Claude Code breaks. Also records that adding a silent-first Keychain read on the user-action path buys nothing, so the Antigravity pattern is not worth porting there either. * feat: attribute Pi/OMP usage to the real model and break the day down per model (chattymin#225) * feat: attribute Pi/OMP usage to the real model and break the day down per model * fix: gate the Pi per-model breakdown to Pi and carry it through the provider --------- Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> * fix: align Antigravity limits stale UI and refresh indicator with Claude (chattymin#245) * docs: add screenshots and README tour rows for session key, per-model breakdown, and animation quality * release: bump version to 2.5.3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: keep the menu bar animating in Low Power Mode, capped at Power saver (chattymin#250) Low Power Mode used to drop the menu bar sprite to a two-frame bob, and the check only ran while building frames — toggling the mode mid-session never rebuilt anything, so an already-running GIF kept its full frame rate, and a bob built during Low Power survived after leaving it. The Power saver preset is within +0.5 recomposites/s of the bob, so cap the effective frame floor there instead of dropping the GIF: the stored animationQuality is never mutated, the cap is derived at the consumption site, and a power-state observer rebuilds frames the moment the mode toggles (the effective floor is part of menuSpriteKey). * perf: swap menu bar sprite frames through a layer instead of button.image (chattymin#255) Assigning `button.image` redraws the entire NSStatusBarButton on every animation frame — and that redraw includes the two-line attributed title, not just the 22px sprite. At the default 5fps the text re-render is always on. Route frame pixels through a dedicated sublayer's `contents` instead: an already-uploaded bitmap is swapped without entering the view drawing path. `button.image` keeps a transparent placeholder so AppKit still owns `imagePosition`, title layout and status item width; it is only reassigned when the sprite size changes. Measured (A/B probe — same timer, 5fps, two-line title, 60s x 2 rounds): button.image + CATransaction 2.00 ms/frame button.image alone 3.51 ms/frame layer.contents 0.27 ms/frame Live app before/after (`sample`, main thread): timer path 235/17212 (1.37%) -> 33/16581 (0.20%) CA::Transaction::commit 163 -> 14 _NSViewDrawRect 105 -> 0 This is a different axis from the existing transition-suppression rule, which still holds and is kept: assigning outside a CATransaction measured worse (3.51 ms), not better. If the conversion ever fails the code falls back to `button.image`, which would make the saving disappear while the animation still looks correct. A test pins the conversion for every sprite shape and bob phase, and asserts that nil is actually reachable so the guard cannot silently stop being able to fail. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: drop stale ccusage references from runtime comments (chattymin#256) ccusage was removed in b762be9 (shipped in v2.1.0) and the app has parsed local logs directly ever since. Four comments still describe the refresh path as spawning it, which no longer matches what the code does: a refresh parses local logs and queries limits, and the only subprocess left is codex. These are load-bearing comments — they explain why polling is suspended on display sleep and why App Nap is disabled — so a reader takes them at face value. During a battery investigation they sent me looking for a per-refresh ccusage spawn that does not exist. The other ccusage mentions are deliberate and kept: ModelPricing (the pricing table was reverse-derived from it), Models (the JSON shapes originated there), LocalUsageProvider / LocalUsageReader ("replaces ccusage" context) and scripts/parity-check.sh (an intentional cross-check tool). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Seong SeungHyeon <73338585+ite476@users.noreply.github.com> Co-authored-by: Boris <148207123+Borisserz@users.noreply.github.com> Co-authored-by: Justin Jeong <justin.jeong@buzzvil.com> Co-authored-by: 개발민수 <minsoo010@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6118@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Florian Behrend <56365738+florianbehrend@users.noreply.github.com> Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> Co-authored-by: Igor Borges <igor@borges.dev> Co-authored-by: Patrick.Park <pdm001125@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6120@naver.com> Co-authored-by: Manuel <97159984+mledutke@users.noreply.github.com> Co-authored-by: Kirill Korikov <korikov.kirill@gmail.com> Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Co-authored-by: Cory Eurom <ce@Corys-MacBook-Pro.local>
lukemonaghan
pushed a commit
to lukemonaghan/PokeTokenBar
that referenced
this pull request
Sep 3, 2026
Reviewed the 14 upstream commits this fork had not yet assessed. Two were worth taking as lessons rather than code; the rest are recorded so they never resurface. Upstream chattymin#243 fixes errSecParam(-50) from pairing kSecMatchLimitAll with kSecReturnData. That repairs damage from chattymin#232, which this fork never took, so the bug does not exist here. Ported the guard only: the production query moves to OAuthCredentialData.claudeKeychainQuery so the test can fire the real query at the Security framework rather than a reconstruction of it, which is what made the original slip through. Verified by injecting kSecMatchLimitAll and watching it return -50 on both prompt paths. Upstream chattymin#247 is pure docs and worth having: Claude's refreshToken rotates on use, so renewing it from here would leave Claude Code holding a dead token and force the user to re-login. Ported without its framing of the session key as the alternative, which this fork declined. Notable decline: chattymin#241 stores a full claude.ai account cookie as plaintext JSON on disk. The Keychain-prompt friction is real, but the answer is a stable signing identity, not a credential at rest. Ledger now reports zero unreviewed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LuceteYang
added a commit
to LuceteYang/PikaTokenBar
that referenced
this pull request
Sep 10, 2026
Brings in the Aside usage provider (chattymin#283), the large catch-log UI stall fix and sprite image cache (chattymin#282), MCP-placeholder account ordering that kills the duplicate Keychain prompt (chattymin#280), refresh-on-network-reconnect (chattymin#252), the errSecParam Keychain read split (chattymin#243), per-model Pi/OMP attribution (chattymin#225), German UI (chattymin#234), and the rest of the 2.5.3 line. Nothing was rejected. Conflict resolutions worth knowing: - SpriteLoader: took upstream's injectable `directory` but kept `spriteCacheDirectory()` as the default. Upstream hardcodes "PokeTokenBar/sprites" there, which would make both apps share one cache. Renamed the fork's `AppIdentityTests` probe from `.dir` to `.directory`. - build-app.sh: kept the fork's VERSION (1.5.2) and the `PRODUCT` / read_identity split; upstream's rename to a literal `APP_NAME` would bypass AppIdentity. - Localization: the fork's session-key strings and upstream's chattymin#241 copy both landed. Dropped the fork's block — upstream's is the same text plus German. Filled German into the fork-only strings (daily trend, peak, GitHub issue reporting) and kept the issue wording over upstream's mail wording. `dailyTrend` is "Täglich im Monat", not "Täglich diesen Monat": the longer string wraps the caption row and breaks testCaptionStaysOnOneLineInEveryLanguageAtWorstCaseNumbers. - UsageStoreTests: same duplication, 159 lines of a second copy removed. - SessionKeyLimitsProvider: took upstream wholesale. Their copy is a superset — 403 handling plus suppression of the Keychain fallback prompt when a key is configured, which the fork never had. - LocalUsageProvider: took upstream's injectable cache seam, kept the fork's `.local(zeroCost:)` helper. - README ×3: kept the fork's daily-trend row and added upstream's session-key and per-model rows below it. swift test: 1001 passed, 0 failures. test-gate.sh: 91.53% >= 75%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yourconscience
added a commit
to yourconscience/PokeTokenBar
that referenced
this pull request
Sep 15, 2026
* fix: prevent invisible text in floating pet hover tooltip (chattymin#193) * fix: pick up in-place Claude account switch on auto-poll (chattymin#227) (chattymin#228) The OAuth cache returned a still-unexpired token without re-reading credentials.json, so /login to a second Team email left the 5h bars on the previous account. Peek the file before the cache hit; Keychain stays off the auto path. Same class in the Antigravity token cache. * fix: resolve Claude account OAuth across multiple Keychain entries (chattymin#232) * fix: show the Raising badge only on the current evolution stage (chattymin#211) * fix: mark only current evolution stage as raising * docs: clarify Pokédex raising badge semantics * docs: correct Raising badge width note * feat: let users pick animation quality for the menu bar sprite and floating pet (chattymin#212) * fix: cap the sprite frame rate by decimating frames, not stretching them The always-visible surfaces (menu bar sprite, floating pet) capped their frame rate by raising each frame's delay -- max(floor, delay). That keeps the frame count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s = 2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale ("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate; it missed that playback slowed 8x. Two-frame bob animations hid the defect, because holding and decimating are the same thing there. GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated floor-length span, so the loop length matches the original and only the frame rate drops. Same wakeup budget, correct speed, and fewer frames to composite (55 -> 6 at a 0.4s floor). Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to the popover, which runs with tolerance .zero, that made the menu bar look slower on its own. The menu bar cap also moves into a named constant. It was an inline max(0.4, delay) while the pet's cap was a tested constant, so the cap could vanish from the menu bar without any test noticing; both surfaces are now asserted together in testAlwaysVisibleSurfacesAreCapped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: let users pick animation quality for the menu bar sprite and floating pet The frame-rate cap on the always-visible surfaces was a single hardcoded value, and no single value fits: the per-frame cost is a status-bar recomposite, so how smooth it looks and how much battery it costs both vary by device and by sprite. A 22px menu bar hides the low frame rate that is clearly visible on a 96px floating pet. This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s / ~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI languages. No preset can disable the cap; 0 (native fps) is not reachable from the UI, which keeps the idle-wakeup guarantee that the cap exists. The default is Power saver: it is the same frame rate the app used before this setting existed, so an existing user's battery profile does not change and the smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced, 5.1% at Smooth. The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey, SpriteView.frameTaskID) because cached frames are already decimated to a specific rate -- without it, changing the setting would not take effect until the next evolution. The menu bar also observes the setting explicitly: observeStore only tracks menuTitle, so otherwise a change would not show until the next usage poll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cursor): read usage from dashboard API when local tokenCount is zero (chattymin#197) * feat(cursor): fetch usage from dashboard API when local tokenCount is zero Cursor's state.vscdb bubbles often store 0/0 token counts since early 2026, so the SQLite scan under-reports. When the user is signed into Cursor, read the same unofficial dashboard export the web UI uses (with sub::jwt cookie auth from cursorAuth/accessToken), fall back to local bubbles or Downloads CSV exports, and avoid merging dashboard rows with stale bubble estimates. API and in-memory cursor reload cache TTL follow Settings → refresh interval (1/2/5/15 min or manual = refetch on every refresh()). Closes chattymin#196 * refactor(cursor): remove CSV export and local file fallbacks Dashboard JSON API is the sole authoritative source when signed in; keep SQLite bubble scan only as offline fallback. * fix(cursor): satisfy CI env guard and ISO8601 date parsing in tests Route CURSOR_USAGE_API through UsageEnvironment and reuse CompanionStore state dir for the API cache path. Parse fractional-second ISO dates in CursorUsageTests. * fix(cursor): extract AppStatePaths for nonisolated cache directory CompanionStore.defaultURL() is MainActor-isolated; share PTB_STATE_DIR resolution via AppStatePaths for Cursor API disk cache. * fix(cursor): harden dashboard usage fallback Keep cached usage account-scoped, reject incomplete pagination, preserve authoritative empty responses, and make paginated event IDs unique. * fix(cursor): harden API fetch, cache, logging, and pagination tests Add explicit fetch failure reasons and HTTP status logging, ephemeral URLSession, request/page deadlines, account-scoped cache TTL and coveredSince checks, safer pagination/id handling, and SQLite fallback when stale cache is non-authoritative. * test(cursor): fix transport test closures for CI Avoid throwing closures in injected transport and compare empty fetch results by count. * test(cursor): use Sendable page recorder in pagination test Fix Swift 6 concurrency error when capturing mutable state in injected transport. * fix(cursor): accept legacy disk cache without accountIdentifier Migrate pre-account-scoped cache files instead of ignoring them on upgrade. * fix(cursor): send epoch-ms range and read usageEventsDisplay The dashboard endpoint was never exercised while the CSV export was the primary source, so two request/response mismatches stayed hidden until CSV was removed: - startDate/endDate were sent as ISO8601, which the endpoint answers with HTTP 500. It expects epoch milliseconds as strings. - Events are returned under usageEventsDisplay, not usageEvents/events, so parsing failed even once the request succeeded. Pagination now also uses totalUsageEventsCount when the response carries no pagination object. Verified live: 239 events fetched. * docs(cursor): sync ko/ja READMEs with dashboard API data source The English README gained the cursor.com data source row and the updated Privacy host list, but the Korean and Japanese translations still listed state.vscdb as Cursor's only source and claimed seven outbound hosts. CLAUDE.md requires all three READMEs to stay in sync. Also notes that the offline disk cache is account-scoped and capped at 6 h, so readers know how stale a fallback reading can be. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: read Kiro CLI 2.20+ JSONL sessions (chattymin#236) (chattymin#238) * fix: read Kiro CLI 2.20+ JSONL sessions instead of only data.sqlite3 Kiro stopped writing new turns to the SQLite store. Scan ~/.kiro/sessions (cli/*.jsonl and v3 messages.jsonl) while keeping the legacy database, and register KIRO_HOME / KIRO_CLI_HOME so the GUI app can see those overrides. * fix: keep every v3 Kiro turn when timestamps are missing Flat messages.jsonl lines share session.json createdAt, so millis ids collapsed under dedupKeepMax. Key v3 entries by turn index and count tool_call args as output, matching the writer-shaped parser. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Add oh-my-pi (omp) usage provider (chattymin#214) * Add oh-my-pi (omp) usage provider Parse pi-format session JSONL under ~/.omp/agent/sessions recursively: every assistant message's usage event is summed (rewound branches are already billed; subagent sessions count since their tokens are not folded into the parent), plus compaction/branch_summary envelope usage, mirroring parsePiFile's semantics. Per-event cost.total is trusted over the pricing table when reported; OMP_CODING_AGENT_DIR overrides the agent home (verified against the omp binary's env vars). bridge/ conversion copies are excluded: they mirror sessions whose usage is already counted at the original source (verified against a real bridge file duplicating a -Projects/ session). Also make testHermesAcceptsMillisecondStartedAt timezone-independent (it hardcoded a local day that only holds at UTC+0/east) and cover the previously uncovered omp reader branches. * Use omp as the provider display name The tool ships as `omp` (omp.sh, the `omp` binary, Homebrew `can1357/tap/omp`), so the provider tab and Settings now show that name instead of `OMP`. The README tables keep the repository name alongside it as `omp (oh-my-pi)` for discovery. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * Update readme (chattymin#239) * Add Trendshift badges to README * Update Korean README with Trendshift badges * Update README.ja.md * feat: fetch official limits with a claude.ai session key (no Keychain prompt) (chattymin#241) * feat: fetch official limits with a claude.ai session key (no Keychain prompt) Auto-polling never reads the Keychain by design, so once the cached token expires and ~/.claude/.credentials.json is absent, official limits freeze until the user presses "Refresh limit token cache" -- and that press raises the password dialog on unnotarised builds, where an "Always Allow" grant does not survive the next code-signature change. This adds an opt-in second credential path. Paste a claude.ai sessionKey cookie under Settings -> Advanced and limits come from claude.ai/api/organizations/{org}/usage, which returns the same schema as api.anthropic.com/api/oauth/usage -- so LimitStatus is reused unchanged and no popover row changes shape. The path touches no Keychain, so auto-polling keeps limits current and there is no ACL grant to lose on the next update. Without a key nothing changes: ChainedLimitsProvider falls through to the existing OAuth path and no file is created. Organization selection is not "the first one". A claude.ai account also returns orgs the user merely logged into, and API-only orgs that answer 403, so picking first yields a permanent and plausible-looking 0%. Candidates are filtered to chat-capable orgs, probed in parallel, and the one with usage is selected and cached. A cached org that later returns 403 triggers rediscovery. A dead key answers 403, not 401 -- verified by flipping one character of a stored key in a running build, where the usage endpoint and /api/organizations both returned 403. Since /api/organizations is not organization-scoped, a 403 there means the key itself can see nothing, so it maps to sessionKeyInvalid. Per-organization 403s keep their existing meaning and still trigger rediscovery. Expiry carries its source (limitsAuthExpiry: .oauth | .sessionKey) rather than a single boolean, because the two remedies are opposites: a session key must be re-copied from the browser, while OAuth only needs a Keychain re-read. Collapsed into one flag, a session-key user is told to run Claude Code once -- which does nothing for a dead cookie -- and the retry button reads the Keychain, raising the very prompt the session key exists to avoid. The settings badge reads "Expired" while the dead key is still stored, and the banner opens Settings with the Advanced disclosure already expanded, since the key field lives inside it. The key is stored as an owner-only (0600) plaintext file, not in the Keychain: an app-owned Keychain item is the very class of defect this removes, because a cdhash change invalidates its ACL and the prompts return. The settings copy states the storage model and that a browser logout invalidates the key. Note for anyone testing this: curl cannot verify these endpoints. Cloudflare answers it with its own 403 challenge regardless of the cookie, so it will confirm any theory brought to it. Only URLSession gives a real answer; the unit tests seam out the HTTP layer instead. Refs chattymin#240 * docs: list claude.ai in the outbound-host section (en/ko/ja) The session-key path adds a twelfth outbound host, and the privacy section enumerates every host the app talks to. * fix: keep a configured session key from falling back into the Keychain A stored-but-rejected key used to pass allowKeychainPrompt straight through to the OAuth fallback, so pressing Refresh raised the very password dialog the key exists to avoid. The remedy for a dead key is re-pasting it, not the Keychain, and the expiry banner already routes there. Users with no key keep today's behaviour, which the second test locks. --------- Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: keep released Pokémon in the Pokédex (chattymin#242) Buying an egg discarded the active Pokémon without touching `dex`, so a species that was only backed by that companion vanished from the Pokédex. That was the one path where the collection screen could shrink, which contradicts the promise it makes. `buyEgg` now records the released companion as a `DexEntry` carrying `releasedAt`. The Pokédex folds `state.dex`, so species retention follows automatically; only the capture log distinguishes the two, with a `Released` badge next to the existing `Raising` one. Three rules the record follows: - Only reached forms are credited (`pathIDs.prefix(stageIndex + 1)`). Using `plannedPathIDs` would make buying an egg a shortcut to filling the Pokédex with evolutions never reached. - Shiny follows `currentIsShiny`, so releasing a disguised Ditto does not reveal it early. - `collectedFinals` is untouched — a released companion was not raised to its final form, so it must not count toward completion or branch weighting. `releasedAt == nil` means "graduated", so existing saves need no migration. A representative pinned to a released species now survives the release, because the species is still owned. * feat: add German UI language (chattymin#234) Add native German copy, official Pokémon terminology, locale detection, and regression coverage so German users get a complete localized experience. Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> * fix: split the Claude Keychain read so it stops failing with errSecParam (chattymin#243) kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that pair with errSecParam (-50) during parameter validation, before it looks for any item. The query added in chattymin#232 therefore never succeeded for anyone whose credentials live only in the Keychain — an approved ACL, a re-login, or a single matching item made no difference. Measured on a real install: 24 consecutive -50 failures, zero successes. Enumerate accounts with attributes only, then read each candidate's data with kSecMatchLimitOne, preserving chattymin#232's intent of picking the entry that carries claudeAiOauth. Falls back to an unscoped single read when no account attribute comes back. The old tests exercised extractDataItems with synthetic arrays, so SecItemCopyMatching was never on the test path and the defect sat one layer above what they covered. The new guard aims the production queries at a service name that cannot exist -- nothing matches, so no ACL or prompt is involved -- and asserts the framework does not answer errSecParam. extractDataItems is removed along with its tests; it has no production caller left. * docs: record why Claude's refreshToken must not be used for renewal (chattymin#247) The Keychain credential carries refreshToken and refreshTokenExpiresAt next to a ~5h accessToken, which makes "renew with it and stop touching the Keychain every few hours" an obvious idea. It is the wrong one: Claude Code's refresh handler replaces its stored refreshToken with the value from the response, so the token rotates. An external app renewing on its own would leave Claude Code holding a dead token and force a re-login, and the only way around that is writing into someone else's credential store, which the app-owned-Keychain rule already forbids. Antigravity can do this because Google does not rotate — its cache reuses the same refreshToken across renewals. The asymmetry is the providers' token semantics, not a gap on the Claude side. Rotation is inferred from the replace-on-store behaviour rather than tested; the cost of being wrong is breaking the user's primary tool, so the experiment was not run. The decision rests on the asymmetry instead: at best it is a weaker version of what the session key already provides, at worst Claude Code breaks. Also records that adding a silent-first Keychain read on the user-action path buys nothing, so the Antigravity pattern is not worth porting there either. * feat: attribute Pi/OMP usage to the real model and break the day down per model (chattymin#225) * feat: attribute Pi/OMP usage to the real model and break the day down per model * fix: gate the Pi per-model breakdown to Pi and carry it through the provider --------- Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> * fix: align Antigravity limits stale UI and refresh indicator with Claude (chattymin#245) * docs: add screenshots and README tour rows for session key, per-model breakdown, and animation quality * release: bump version to 2.5.3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: keep the menu bar animating in Low Power Mode, capped at Power saver (chattymin#250) Low Power Mode used to drop the menu bar sprite to a two-frame bob, and the check only ran while building frames — toggling the mode mid-session never rebuilt anything, so an already-running GIF kept its full frame rate, and a bob built during Low Power survived after leaving it. The Power saver preset is within +0.5 recomposites/s of the bob, so cap the effective frame floor there instead of dropping the GIF: the stored animationQuality is never mutated, the cap is derived at the consumption site, and a power-state observer rebuilds frames the moment the mode toggles (the effective floor is part of menuSpriteKey). * perf: swap menu bar sprite frames through a layer instead of button.image (chattymin#255) Assigning `button.image` redraws the entire NSStatusBarButton on every animation frame — and that redraw includes the two-line attributed title, not just the 22px sprite. At the default 5fps the text re-render is always on. Route frame pixels through a dedicated sublayer's `contents` instead: an already-uploaded bitmap is swapped without entering the view drawing path. `button.image` keeps a transparent placeholder so AppKit still owns `imagePosition`, title layout and status item width; it is only reassigned when the sprite size changes. Measured (A/B probe — same timer, 5fps, two-line title, 60s x 2 rounds): button.image + CATransaction 2.00 ms/frame button.image alone 3.51 ms/frame layer.contents 0.27 ms/frame Live app before/after (`sample`, main thread): timer path 235/17212 (1.37%) -> 33/16581 (0.20%) CA::Transaction::commit 163 -> 14 _NSViewDrawRect 105 -> 0 This is a different axis from the existing transition-suppression rule, which still holds and is kept: assigning outside a CATransaction measured worse (3.51 ms), not better. If the conversion ever fails the code falls back to `button.image`, which would make the saving disappear while the animation still looks correct. A test pins the conversion for every sprite shape and bob phase, and asserts that nil is actually reachable so the guard cannot silently stop being able to fail. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: drop stale ccusage references from runtime comments (chattymin#256) ccusage was removed in b762be9 (shipped in v2.1.0) and the app has parsed local logs directly ever since. Four comments still describe the refresh path as spawning it, which no longer matches what the code does: a refresh parses local logs and queries limits, and the only subprocess left is codex. These are load-bearing comments — they explain why polling is suspended on display sleep and why App Nap is disabled — so a reader takes them at face value. During a battery investigation they sent me looking for a per-refresh ccusage spawn that does not exist. The other ccusage mentions are deliberate and kept: ModelPricing (the pricing table was reverse-derived from it), Models (the JSON shapes originated there), LocalUsageProvider / LocalUsageReader ("replaces ccusage" context) and scripts/parity-check.sh (an intentional cross-check tool). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: keep shop egg cards visible during the egg stage with a disabled buy button (chattymin#261) During the egg stage the shop dropped all three egg cards, which read as "the shop doesn't sell eggs" rather than "you can't buy one right now". The purchase gate itself is intended and unchanged: a shop egg always means "release the current Pokemon and reroll", so there is nothing to reroll while incubating (canBuyEgg still requires an active Pokemon). - shopEntries no longer filters eggs by hasActive; price-ascending merge order is unchanged. - EggCard shows a disabled Buy button during the egg stage with a one-line reason underneath, localized in all seven languages. The state reason takes priority over the insufficient-balance label. - The "eggs omitted when no active" test is replaced by one pinning the new contract: eggs stay visible, canBuyEgg/buyEgg stay blocked for every tier, and a blocked attempt does not touch the wallet. Co-authored-by: p <p@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix: resolve the codex binary bundled inside ChatGPT.app for limit display (chattymin#262) Codex usage is parsed from ~/.codex/sessions logs, but limits are fetched by spawning the codex CLI (app-server account/rateLimits/read). Users who run Codex through the ChatGPT desktop app have no standalone codex install, so limit fetch was skipped ("codex binary not found") while usage still showed. Add the ChatGPT.app bundled CLI path as the last candidate so dedicated installs keep precedence. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: raise the floating pet size ceiling from 192px to 384px (chattymin#267) The desktop pet slider topped out at 192px. Double the maximum so the companion can be sized up to 384px. Sprites render with `.interpolation(.none)`, so the extra scale stays crisp pixel art instead of blurring, and `FloatingPetController` already observes `floatingPetSize`, so the panel resizes with the sprite as the slider moves. Also cover the other branch of `max(petSize, bubbleMinWidth)`, where the pet drives the panel width rather than the bubble column. Only 184 and 192 reached it before; now it is most of the range. Claude-Session: https://claude.ai/code/session_01X91ErNfhphSw9gC1pqjjjN Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Show the absolute reset time next to limit countdowns (both 5-hour and weekly) (chattymin#263) * feat: enhance 5-hour reset display with reset time aside countdown * feat: enhance weekly reset display with reset date and time aside countdown (with weekday if not near) * chore: update staled comment about weekly relative-only display * feat: trigger immediate refresh upon network reconnection (chattymin#252) * feat: trigger immediate refresh upon network reconnection * fix: add lock synchronization and flapping debounce to NetworkReachabilityMonitor * fix: avoid UI stalls when opening large catch logs (chattymin#282) * fix: avoid UI stalls when opening large catch logs Render catch log rows lazily and reuse NSImage instances across synchronous and asynchronous sprite and item loading paths. - Configure the shared image cache with countLimit set to 64 - Preserve cache-key isolation and existing fallback behavior - Add regression tests for large-log rendering and image reuse - Document the root cause, validation, and memory measurement limits * test: keep image cache tests compatible with Xcode 16 Keep NSImage results on MainActor and use Task<Void, Never> to await completion without requiring NSImage to conform to Sendable. Preserve concurrent-load and object-identity checks, and document the CI toolchain compatibility issue in the defect log. * feat: add Aside local token usage provider (chattymin#283) * feat: add Aside local token usage provider * fix(aside): harden the local usage provider after review - Read custom scan roots on every fetch instead of snapshotting at init - Anchor turns on COALESCE(finished_at, last_message_timestamp) so long-running turns land in the day / 5-hour block they were generated in - Skip an unreadable state.db (unmigrated profile, foreign DB) but throw when every database fails so a transient error keeps previous values - Drop zero-token (aborted) turns so they never form an empty active block - Document that sessions.model is the session's current model, not per-turn - Tests: custom roots re-read per fetch, midnight-spanning turn, open / prepare / partial-scan failures, all-failed throws, zero-token filter Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY * docs: record why the Aside provider needed three review rounds - provider-extension: new local sources must sit on LocalAdditionalUsageCache; trace UsageStore.refresh before changing a reader's throw/nil/[] semantics - defect-log: chained regressions from fixing failure handling provider-side without reading the consumer; transient vs permanent SQLite codes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY * fix: move the Aside provider onto LocalAdditionalUsageCache Resolves the three open findings from the Aside review (docs 96fc6ab): - Deleting an Aside session cascades to its turns and subtracted already-counted usage from today/week/month. The `.aside` cache case now merges each scan with the previous entries (`dedupKeepMax(existing + loaded)`) like Kiro, which also shares one scan between fetchDaily and fetchEnrichment. - The reader no longer throws. A permanent schema mismatch (`no such table`, NOTADB) used to throw on every refresh and freeze `lastUpdated` app-wide; unreadable databases are skipped and an all-failed rescan returns `[]`, which the keep-max merge treats as "nothing new" instead of zero usage. - The `finished_at` arm of the COALESCE is now covered by a fixture in both directions. Provider struct moves next to its siblings; the reader keeps its own file and takes `roots:` for tests. defect-log records the final no-throw decision. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY * fix(aside): drop per-model rows, test the cache merge behaviorally, survive a recreated store Follow-up to the Opus review of f232b96: - `includeModels` off: `sessions.model` is the session's current model, not a per-turn record, so a per-model breakdown relabelled earlier turns on every cache refill. Only Pi opts in. - The keep-max merge is now exercised through the real cache instead of a source-text assertion: `LocalAdditionalUsageCache(rootsOverride:clock:)` and `LocalAsideProvider(cache:)` let a test step past the 30 s entry. The first behavioral run caught a `MAX(id)` reset-detection attempt that misread a session deletion as a recreated store; that approach was dropped. - Entry ids carry the state.db inode, so a recreated profile store (AUTOINCREMENT restarts at 1) no longer hides its new turns behind cached pre-reset ids. - Reader doc and defect-log now say the deleted-session guarantee lasts until the cache is invalidated; fixture mirrors the real schema (AUTOINCREMENT, aborted_at) and an aborted turn with tokens is asserted counted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY * chore(aside): review nits — id prefix, model comment, seam naming, month-boundary skip - Entry ids carry the `aside|` prefix like every sibling source. - The `sessions.model` comment now names its only consumer (pricing fallback). - `rootsOverride` was consulted by `.aside` only; renamed `asideRootsOverride`. - Cache tests skip within a minute of a month boundary, where stepping the clock 31 s would drop `existing` on the new month key. - provider-extension: recreated-store guard is inode-in-id for mutable rows or the Copilot-style `didReset` probe for append-only ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(limits): prioritize user account over MCP placeholders to prevent duplicate Keychain prompts (chattymin#280) - Enumerate and prioritize candidate Keychain accounts (NSUserName() > email > general > unknown/none) so the primary user account with valid claudeAiOauth is queried first, eliminating redundant authentication dialogs caused by MCP dummy entries. - Guard against prompt bombing by immediately breaking the enumeration loop when the user cancels or authentication fails (errSecUserCanceled, errSecAuthFailed). - Avoid redundant unscoped queries when the service is completely absent from the Keychain (errSecItemNotFound). - Add 7 comprehensive unit tests verifying ordering hierarchy, edge-case normalization, and case-insensitive user matching. - Update defect log with multiple Keychain item prioritization rules. * feat: add difficulty multipliers and a popover toggle for the floating pet (chattymin#244) Two independent, opt-in controls. Both default to current behaviour, so an existing install sees no change until the user touches them. Difficulty multipliers (Settings) - Two independent multipliers: growth (egg incubation, evolution and graduation thresholds) and shop prices. - Applied at the consumption sites in CompanionStore rather than to the constant tables. The two must stay independent, and graded egg prices derive from a ratio of graduationTotal -- scaling that table in place would let the growth slider move shop pricing as a side effect. - Thresholds are read through private eggHatchThreshold / stageThreshold helpers rather than PokemonBalance directly, so a call site that forgets the multiplier cannot silently fall back to default difficulty. - Persisted in UserDefaults alongside the other Settings preferences, not in CompanionState. Difficulty is a preference, not save data, so it stays out of save export/import and SaveTransfer is untouched. - Values from defaults are clamped on read. UserDefaults is writable from outside via `defaults write`, so 0, negative and NaN can genuinely arrive, and a multiplier of 0 would produce a zero threshold (division by zero in progress, degenerate evolution loop). - Applied live: lowering growth re-evaluates evolution and hatching immediately instead of waiting for the next usage poll (default 120s). - RareCandy.xp is deliberately not scaled. Scaling it would cancel against the thresholds and make candy the one thing immune to difficulty. - The slider is log-mapped: the range spans five orders of magnitude, and on a linear track 1.0 sits at 5% of the track with everything below 0.1 collapsed into a single tick. Values snap to two significant figures, and the 100% snap window is defined in position space -- on a log axis 1px near 1.0 is worth ~7% of multiplier, so a value-space window would be narrower than a pixel and drag would skip past the default with no way back. Popover footer - An eye button toggles floating pet visibility. It reads and writes the same store.floatingPetEnabled property as the Settings switch, so the two cannot diverge; @observable propagates each to the other with no explicit syncing. Tests - Difficulty is covered behaviourally (same token input, different outcome) rather than by asserting the arithmetic: evolution and hatch points move with growth, shop gates and charged amounts move with price, and neither multiplier moves the other's outcome. - Clamping is covered for the values that can actually reach it from defaults (0, negative, NaN, out-of-range), including that a stored 0 cannot produce a zero threshold. - Slider mapping is covered by round-trip, endpoint and drag-reachability of the 100% default. - The footer eye symbol is asserted in both states; inverted icons are the failure mode that reads as "hidden but looks on". * feat: give repeat hatches 2x growth (chattymin#254) Co-authored-by: Patrick.Park <pdm001125@naver.com> * feat: give each Pokémon persistent individual values and a Pokédex detail page (chattymin#264) * feat: give repeat hatches 2x growth * feat: give each Pokémon persistent individual values and a Pokédex detail page Every hatched Pokémon now rolls individual values that persist in the companion save — IVs, gender, ability and a level-up moveset that follows its growth — so the same creature stays the same creature across launches. Tapping a Pokédex cell opens a detail page with nature-modified computed stats, species data and the full supported learnset. Immutable PokéAPI metadata is fetched at runtime and cached under Application Support, never bundled. The parser keeps only the supported version group, so neither memory nor disk holds PokéAPI's cross-generation move history (82 KB -> 5.5 KB per species for Pikachu). - Save schema 1 -> 2. v1 saves still import; a one-time companion-state.pre-profiles-v1.json backup is written before migrating, and imported profiles are clamped at the trust boundary. - A revealed Ditto rebases its profile, so it reports Ditto's genderless identity, ability and moves instead of the disguise's. - scripts/test-gate.sh now covers PokemonProfile.swift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyRfL85nwsaUytvcNA9qqm * fix: align imported Pokémon levels with growth rules --------- Co-authored-by: jun <jun@daangn.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Patrick.Park <pdm001125@naver.com> * feat: show this month's day-by-day usage trend in the popover (chattymin#270) Week and month usage each collapse to a single PeriodUsage scalar, so the popover can say how much this month but not how the last few days looked. The data is already in memory. enrichmentScanStart covers the whole current month for every provider and Entry already carries localDay; period() filters on it, keeps the sum and drops the rest. So this is a group-by over entries that were already loaded -- no Entry field, no cache version bump, no re-parse, no new I/O, no change to the scan window. - LocalUsageReader.monthDailySeries builds the date axis from the month range and folds totals onto it rather than grouping entries by localDay. The scan is an mtime filter, so a session that began last month and ran into this one is read in full and drags last month's entries along; emitting those would draw a partially filled previous month, since the other files from last month were never scanned. Days with no usage are explicit zeros -- bar position is the date, so dropping them would shift the axis. - ProviderEnrichment.monthDaily / ProviderSnapshot.monthDaily are optional: a provider that cannot produce a series stays nil and drops out of the aggregate, and the rest still add up. - The twelve copied fetchEnrichment bodies now share one assembly site, ProviderEnrichment.local(entries:now:zeroCost:). Copying the assembly per provider is the shape the append-only watermark loop had before chattymin#157 -- a field added later gets filled in some copies and not others. No behaviour change; zeroCost keeps Codex and Pi at no money. - The popover header gains a bar row with a date axis (day 1, every seventh day, and always today), a short tick under weekends, and a caption naming the day you point at, falling back to today. Its visibility gate is peak > 0, not array emptiness: producers fill unused days with zeros, so an isEmpty gate would show an empty row to someone who has not used the tool this month. Scope is the current month only, baked into the helper rather than parameterised. Tests cover the cross-month truncation through a real mtime scan, dense zeros, the sum(series) == monthTotal invariant, providers that report no series, axis label collisions across all 31 possible "today" values, and the caption staying one line in all seven languages. The day axis and weekend marking take an injectable time zone and calendar: a fixed 86400-second stride passed the axis test on a machine without DST and only failed under TZ=America/Los_Angeles, and which days are the weekend is a locale decision. Co-authored-by: Patrick.Park <pdm001125@naver.com> * feat(limits): add ambient Claude Keychain help popover with direct session key navigation (chattymin#275) * feat(limits): add Claude Keychain limitation guidance banner and help popover * feat(settings): auto-scroll and focus Claude session key on navigated open * refactor(limits): remove noisy post-refresh banner in favor of ambient help popover * docs(defect-log): update Claude Keychain guidance entry to reflect ambient help popover * test: allow keyboard focus in settings render fixture * test: separate desktop focus checks from hosted rendering --------- Co-authored-by: Patrick.Park <pdm001125@naver.com> * fix: count Codex total-only turns and price Fable 5.1 cache reads (chattymin#278, chattymin#277) (chattymin#279) Codex sometimes emits last_token_usage with every component at 0 while total_tokens is set; trust that total when it is the session signal, but keep fork zero-context phantoms at 0. Add an exact Fable 5.1 rate row so the fable family fallback no longer overstates cache reads 4×. * fix: remove floating pet toggle from popover footer (chattymin#284) * fix: keep dex sprite animation consistent when opening details (chattymin#285) * fix: match quota graph fill to remaining display mode (chattymin#286) * fix: save difficulty changes without advancing earned progress (chattymin#287) * fix: distinguish estimated and unavailable usage costs (chattymin#289) * fix: estimate costs only for known model prices * fix: distinguish estimated and unavailable usage costs * fix: complete selected-language localization across the app (chattymin#290) * fix: localize Pokemon detail names with English fallback * fix: refresh legacy dex names without losing saved translations * fix: resolve error messages in the selected language * fix: localize remaining interface labels and accessible controls * fix: share dex name refreshes and preserve localized error diagnostics * docs: require the repository pull request template * fix: align quota percentages at the trailing edge (chattymin#292) Co-authored-by: Codex <noreply@openai.com> * fix: display usage costs as plain dollar amounts (chattymin#293) Co-authored-by: Codex <noreply@openai.com> * fix: avoid English flashes while loading Pokemon detail names (chattymin#295) Co-authored-by: Codex <noreply@openai.com> * fix(limits): support nested token file and automatic OAuth refresh for Antigravity (chattymin#274) * fix(limits): support nested token object in Antigravity token file - Parse nested token object (`{"token": {"access_token": ...}}`) in jetski-standalone-oauth-token file by reusing `parseCredential` - Prevent unnecessary fallback to macOS Keychain and avoid repeated password prompts caused by CLI ACL reset - Support background token refresh using refresh_token when available in token file - Add regression tests in CredentialSwitchCacheTests and record entry in defect-log.md * fix(limits): include client credentials in Antigravity token refresh * fix(limits): refine OAuth form encoding and document public client credentials * fix(limits): support top-level access_token in Antigravity token parser * fix(limits): guard near-expiry account switch and support mock OAuth refresh flow - Guard retained cache over expired token file with isSameSourceCredential to prevent retaining stale cached account on near-expiry switch - Inject urlSession into AntigravityTokenCache with default .shared to enable clean test mocking - Add mock HTTP integration tests with MockOAuthURLProtocol covering token refresh, subsequent poll cache retention, and failure fallback - Refine defect-log.md to clarify Keychain access boundary between auto and manual paths, and note Antigravity client-specific requirement for client_secret * fix: require release notes and verified contributor attribution (chattymin#296) * docs: refresh release tour and screenshots for v2.5.4 (chattymin#297) * release: bump version to 2.5.4 * feat: surface omp usage per model, matching the Pi breakdown (chattymin#276) * feat: combine token usage from a remote Mac over SSH --------- Co-authored-by: Seong SeungHyeon <73338585+ite476@users.noreply.github.com> Co-authored-by: Boris <148207123+Borisserz@users.noreply.github.com> Co-authored-by: Justin Jeong <justin.jeong@buzzvil.com> Co-authored-by: 개발민수 <minsoo010@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6118@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Florian Behrend <56365738+florianbehrend@users.noreply.github.com> Co-authored-by: Patrick.Park <patrick.park@buzzvil.com> Co-authored-by: Igor Borges <igor@borges.dev> Co-authored-by: Patrick.Park <pdm001125@naver.com> Co-authored-by: Sanghwan Park (Otis) <jae6120@naver.com> Co-authored-by: Manuel <97159984+mledutke@users.noreply.github.com> Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Co-authored-by: p <p@local> Co-authored-by: Yongjae Kim <yojkim@lbox.kr> Co-authored-by: Michael Mugnai <Maik93@users.noreply.github.com> Co-authored-by: pinggoo <62925498+parkjh2568@users.noreply.github.com> Co-authored-by: Youngmin Ryou <yym3055@gmail.com> Co-authored-by: Issachar Vinajeras <issacharv@gmail.com> Co-authored-by: parkjun5 <58926619+parkjun5@users.noreply.github.com> Co-authored-by: Cedric Golla <50961672+CCedDev@users.noreply.github.com> Co-authored-by: jun <jun@daangn.com> Co-authored-by: Codex <noreply@openai.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a regression from #232: official Claude limits fail with
keychainUnavailable(-50)for every user whose credentials live only in the Keychain.kSecMatchLimitAllcannot be combined withkSecReturnData— macOS rejects that pair witherrSecParam(-50) during parameter validation, before it ever looks for items. So the query introduced in #232 could never succeed: not with an approved ACL, not after re-login, not with a single matching item. Users who have~/.claude/.credentials.jsonwere unaffected, because that path never touches the Keychain, which is why it got through review.Measured on a real install: 24 consecutive
-50failures and zero successes, starting the moment the build landed.Fix
Split the read into two steps, keeping #232's intent — pick the entry that actually carries
claudeAiOauthwhen several share the service name:kSecMatchLimitAll+kSecReturnAttributes— enumerateacctnames only, no data.kSecMatchLimitOne+kSecReturnData— read and parse one item.Falls back to an unscoped single read when no account attributes come back, so the common single-item case is unchanged. Error discrimination is preserved:
credentialMissingAccountOAuthstill fires when entries exist but none carry account OAuth.Verified against a real Keychain with a no-UI query: both new queries return
errSecSuccess, while the #232 query still returns -50.Why the tests missed it
extractDataItems(from:)was tested with synthetic arrays, but the defect was in the query that feeds it —SecItemCopyMatchingwas never on the test path. Asserting the dictionary's shape would not have helped either: which key combinations are invalid is knowledge only the Security framework has.So the new guard calls the real API. It aims the production queries at a service name that cannot exist, so nothing matches and no ACL or prompt is involved, and asserts the status is not
errSecParam. The expectation is not "succeeds" but "the parameters are valid" (errSecItemNotFound). Reverting the data query tokSecMatchLimitAllturns it red — confirmed by injection.extractDataItemsis removed together with its tests; it no longer has a production caller, and leaving it green would protect nothing.Type of change
UI changes
None.
Checklist
swift buildandswift testpass locally