Skip to content

fix(usage): count Codex compaction usage from token_usage_record - #8479

Merged
farion1231 merged 3 commits into
farion1231:mainfrom
Chaoqi31:fix/codex-remote-compaction-usage
Oct 20, 2026
Merged

farion1231 merged 3 commits into
farion1231:mainfrom
Chaoqi31:fix/codex-remote-compaction-usage

Conversation

@Chaoqi31

@Chaoqi31 Chaoqi31 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Why

Codex 0.153+ writes a token_usage_record for every model response. A remote compaction writes that record and then a token_count whose last_token_usage is all zeros. The importer billed only token_count, so those calls became 0 rows. One local thread had 26,705 input and 491 output imported as nothing.

Scope

  • src-tauri/src/services/session_usage_codex.rs
  • A token_usage_record is held until the next token_count. If that last_token_usage matches the record, the token_count row stays the only row. If last_token_usage is present and all zeros, the record is imported as codex_thread:{root_thread_id}:usage:{response_id}.
  • Existing token_count rows keep their event_index. The new suffix does not renumber later requests.
  • Existing cursors stay. Compaction lines already scanned before the upgrade are left as they are on main. The new path runs only for lines read after the upgrade.

Tradeoffs

A token_usage_record that does not match a later non-zero last_token_usage stays timing-only. Billing every mismatch double-counted a latency fixture whose record was 1 input and 999 output while the following token_count last was 200 and 700.

The record is not billed at end of file. A live session often writes the record before token_count. Billing at end of file would insert a usage: row, and the next sync would also insert the matching token_count row.

The signal is a zero last_token_usage, not a zero total. Real files keep a non-zero total and a zero last after compaction.

A compaction record copied into a child fork is not covered here. That replay prefix is issue 7917 item 1.

Pre-upgrade compaction usage is not recovered. A one-time reread of already-scanned files would insert detail rows for days that already live in usage_daily_rollups.

Blast Radius

This touches Codex session import. New compaction rows use a separate request id, so existing detail rows are not rewritten. Files whose cursor is already current are not reread.

Verification

Before the change, remote_compaction_usage_record_is_imported_when_the_following_token_count_is_zero imported 0 rows and the test expected 1. The sibling case, a record followed by a matching token_count, imported exactly 1 row under the existing event_index.

After dropping the upgrade backfill, cargo test --manifest-path src-tauri/Cargo.toml --lib session_usage_codex::tests passed 87 tests, failed 0, and ignored 1. cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings passed. rustfmt --check was applied to session_usage_codex.rs before the test run.

Section 4 item 3 of #7917. Issue 7917 stays open.

Chaoqi31 and others added 2 commits October 17, 2026 12:46
…record

A remote compaction writes token_usage_record and then a token_count whose last usage is zero. The test expects those tokens to be imported once.

Co-authored-by: Cursor <cursoragent@cursor.com>
A zero last_token_usage after token_usage_record is the compaction call
that token_count never bills. Keep that row off event_index so later
requests stay put, and reread unchanged files once so an existing cursor
does not leave the row behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix — the pairing between token_usage_record and the following token_count looks right. Replaying a copy of my local ~/.codex (3,038 files) against main, this PR only adds the 102 missing compaction rows and leaves every other row unchanged.

One change before merging: please drop the upgrade backfill (the codex_token_usage_record_backfill marker and the forced reread of already-scanned files) and let the new logic apply only to lines read after the upgrade. Existing cursors stay where they are, so compaction usage from before the upgrade stays as it is on main.

The reasons:

  • The first sync after upgrading rereads every already-scanned file. On those 3,038 files that took about 70 s in a debug build.
  • The marker is written only when the whole pass succeeds. If one file keeps failing to parse, the marker is never written and the full reread repeats on every periodic sync.
  • For users who also run the local route, rows older than 30 days have already been rolled into usage_daily_rollups, so the backfilled detail rows count that usage a second time.

Removing that layer also removes its tests, so the change should get noticeably smaller.

A forced reread of already-scanned files counted compaction rows twice once they had rolled into usage_daily_rollups.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Chaoqi31

Copy link
Copy Markdown
Contributor Author

Dropped the upgrade backfill in 3c97d68. codex_token_usage_record_backfill and the forced reread are gone. Existing cursors stay, so compaction usage from before the upgrade stays as it is on main. New lines still import an unmatched token_usage_record.

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for your contribution!

@farion1231

Copy link
Copy Markdown
Owner

Thanks for tracking this down! Compaction calls showing up as 0 tokens was an easy one to miss, and pairing each token_usage_record against the following token_count (instead of billing both) keeps the regular path free of double counting. The regression tests cover exactly the cases that matter, and thanks for dropping the backfill layer along the way.

Two small edge cases I noticed, neither blocking, in case you'd like to follow up:

  1. If a token_usage_record is the last line of the file when a sync runs, the cursor has already moved past it. On the next sync, the all-zero token_count that follows still uses the record's line number for its event, so it gets skipped and that call is missed. Using the token_count line number there should fix it. The window is tiny (tens of milliseconds) and it isn't a regression.
  2. When a forked thread copies the parent's compaction record plus the zero snapshot, those usage events aren't filtered by replay_prefix, so the parent and child could both count them under different roots. I didn't find a real sample locally.

Merged, thanks again!

@farion1231
farion1231 merged commit 8cfe564 into farion1231:main Oct 20, 2026
8 checks passed
Chris-Xie369 added a commit to Chris-Xie369/cc-switch that referenced this pull request Oct 21, 2026
* fix(tray): keep tray panel rows stable and fit scaled displays (farion1231#8572)

Size the provider column to the main panel's height and scroll long lists inside it, so hovering between apps no longer shifts rows under the cursor. Convert between WebView CSS pixels and screen logical pixels using the monitor scale factor on Windows, narrow and clamp the panel to the monitor work area, and let footer buttons shrink. On Windows, left-click on the tray icon now always opens the main window; right-click shows the selected tray style.

Fixes farion1231#8553
Fixes farion1231#8549

* fix(tray): refresh DPI coordinates after cross-monitor show (farion1231#8595)

* fix(windows): remove stale portable marker during MSI installation (farion1231#8529)

* fix(usage): count Codex compaction usage from token_usage_record (farion1231#8479)

* fix(provider-form): stop macOS from capitalizing typed API keys

The API key field is a plain input, so it missed the autocorrect,
auto-capitalization and spellcheck opt-outs the shared Input component
already sets. With the key shown in plain text, a typed "sk-..." key
could be saved as "Sk-..." and fail authentication. Pasted keys were
not affected.

* fix(tray): align quota bars and show time until reset

Lay out each quota block as one grid (label | bar | reset) so bars start
and end at the same x across tiers, and let the quota row extend under
the chevron / "In use" column so bars are no longer squeezed. Replace the
absolute reset date with a compact time-until-reset (at most two units,
e.g. "1d 23h"), with unit strings added to all panel text branches, and
keep quota labels from wrapping.

* feat(tray): mark the balance amount in panel quota lines

Carry an `emphasis` field on tray quota lines and the panel payload,
set to the amount only when a pay-as-you-go balance is left (matching
the card's `balanceLine`), so the panel can color just the number.

* feat(tray): show remaining balances in green in the tray panel

Color only the balance amount green, like the provider card's quota
line, while the label stays muted and a used-up balance keeps its
warning color.

* fix(proxy): stop child processes from inheriting the listener socket on Windows

On Windows the proxy port could stay LISTENING after CC Switch exited,
owned by the PID of the dead process, so the next start failed to bind
with os error 10048 and fell back to direct mode until a reboot or a
port change.

The listener comes from tokio -> mio, and the locked mio 1.1.1 created
Windows sockets with plain socket(), which yields an inheritable handle.
CC Switch spawns long-lived children (terminals via `cmd /c start`, CLI
tools), and std's Command passes inheritable handles to them, so those
children kept the listening socket open after CC Switch was gone.

Bump mio to 1.2.1, which creates sockets with WSA_FLAG_NO_HANDLE_INHERIT
(tokio-rs/mio#1946), and add a Windows-only test asserting the listener
handle is not inheritable so the lockfile cannot silently regress.

Refs farion1231#8552

* fix(webdav): upload the sync manifest as application/octet-stream

CSTCloud Data Capsule's WebDAV server answers PUT manifest.json with
500 "Uploaded object size does not match the upload session" whenever
the body is multi-line JSON sent as application/json. The same pretty
JSON is stored byte-for-byte when sent as application/octet-stream, and
db.sql / skills.zip already upload fine with their own types.

Send the manifest with application/octet-stream. The manifest bytes and
hash stay the same and readers never look at Content-Type, so old and
new versions keep reading each other's manifests. S3 sync is unchanged.

Refs farion1231#8554

* fix(toml): only normalize smart quotes used as string delimiters

normalizeTomlText replaced every curly/fullwidth quote in the text before
TOML parsing, including quotes inside already-valid strings. MCP import and
validation (tomlUtils, mcpDraft) and the Codex config editor therefore
silently rewrote values such as args = ["don’t"] to "don't", and rejected
valid configs like args = ["say“hello”"] or args = ['don’t'] because the
inner quotes became delimiters.

Scan the text instead: smart quotes outside strings are delimiters and
become ASCII; strings opened by an ASCII quote keep smart quotes as
content; strings opened by a smart quote may close with either form;
multi-line strings and comments are handled. To keep the Chinese IME case
working (the editor normalizes each keystroke, so the left quote is already
ASCII when the right smart quote is typed), an ASCII-opened string left
unterminated at end of line is re-scanned with smart closers, and if the
strict result still fails to parse, a lenient scan is used only when it
parses. Escaped smart double quotes in basic strings become \". Text
without smart quotes is returned unchanged.

Refs farion1231#8559

* fix(codex): don't give an expired stashed login back to the proxy

Since farion1231#8431, entering or attaching the proxy for a third-party Codex
route gives back the most recently stashed ChatGPT login when auth.json
has none. The stash can hold a login set aside months ago by a direct
switch with "keep official login" off. Restoring it made the route
table require OpenAI auth, Codex desktop tried to refresh the dead
token and got stuck on the sign-in page, and every exit/enter put it
back again.

The restore now skips a login whose access_token JWT has already
expired. Codex refreshes the token every 8 days and it lives about 10,
so an expired one means the login has not been used for a long time.
Tokens that cannot be decoded are still restored, and the skipped login
stays in the stash so switching back to an official card returns it.

Refs farion1231#8573

* fix(mcp): project url-only servers without a type to Hermes and OpenCode

A unified MCP spec can lack a `type` field when it was pasted in JSON
mode or created via a deeplink, e.g. `{"url": "https://..."}`. Hermes'
and OpenCode's projections defaulted a missing type to stdio, whose
branch only copies command/args/env. Hermes therefore got an entry with
nothing but `enabled: true`, and OpenCode got `type: local` with an
empty command, while the UI reported a successful sync.

Codex's projection already handled this in `spec_transport_type`
(missing or stdio type, no command, non-empty url => http). Move that
helper unchanged into `mcp/validation.rs` and use it from the Codex,
Hermes and OpenCode projections, so all three infer the transport the
same way. Add regression tests for url-only and command-only specs.

Refs farion1231#8579

* fix(mcp): take the app's switch lock before toggling a server

toggle_mcp_app and set_mcp_enabled called McpService::toggle_app without
the per-app switch lock. Each single-server write reads the app's live
config, edits it and writes it back, so two toggles in flight at once (or
a toggle racing a provider switch, a proxy enter or an MCP resync, which
all hold that lock) could overwrite each other and silently drop servers
from ~/.claude.json, config.toml and friends while the database still
showed them enabled.

Add McpService::toggle_app_locked, which takes the same switch lock
resync_mcp_to_apps already uses and then runs toggle_app, and route both
commands through it. toggle_app itself stays lock-free because the tokio
lock is not reentrant and internal callers may already hold it; MiniMax
Code and Pi keep their own file lock inside sync_and_commit, which never
takes the switch lock. A test fires 16 concurrent toggles at Claude and
checks every server lands in the live file (it fails without the lock).

Refs farion1231#8580

* fix(tray): release the tray panel WebView in lightweight mode

Lightweight mode frees memory by destroying the main window's WebView.
Since the tray panel became the default tray UI, the panel is a second
WebView window that is created on the first tray click and afterwards
only hidden, so a WebView stays alive and on Windows the WebView2
processes never exit after entering lightweight mode.

In lightweight mode the panel is now destroyed whenever it is hidden
(and right away when entering lightweight mode while it is already
hidden); the next tray click creates it again. The destroy is deferred
to a later main-thread turn and re-checks that the panel is still hidden
and not about to be revealed, so it never tears the WebView down inside
its own window-event callback or IPC command. toggle() decides "close"
before creating the window so a click that only closes the panel does
not recreate it, and a recreated window starts with a fresh theme state
and, on macOS, fresh glass views.

Refs farion1231#8612

* feat(hermes): warn when a provider key is a Hermes built-in provider id

Hermes routes model.provider to its built-in provider whenever the name is a
canonical built-in id (runtime_provider_custom._shadowed_by_builtin), so a card
keyed "deepseek" or "openrouter" is ignored: Hermes asks for the built-in's own
API key env var and never uses the card's endpoint or key. farion1231#8538 only fixed the
two MiniMax presets by prefilling cc-switch-minimax.

The provider key field now shows a warning for any of the 52 canonical ids
(generated from hermes-agent rc.3-v0.22.0) and suggests cc-switch-<key>. A
locked key gets a variant that says to re-add the provider under another key.
Aliases such as kimi still reach the custom provider and are not flagged.
Saving is not blocked.

* chore(release): v4.0.17

* docs(release): add v4.0.17 release notes

* docs: record the v4.0.17 upstream sync

The ledger lists the three version conflicts, the patches that stayed, and the Windows checks still left for a real machine. The owner-line script now compares v4.0.16 with v4.0.17.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

* test(codex): follow one parent file in the timeline cache

cargo test runs these tests in one process, so another test can put a second path in the parent timeline map. The checks now follow this file. Two cutoffs still have to reuse the same timeline.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

* docs: record the v4.0.17 test results

The ledger now has the Linux test counts and the Codex cache assertion that failed when cargo test shared one process.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

* docs: point the owner-line script at the v4.0.16 base

The comparison tag was already v4.0.16. The opening sentence still named v4.0.14.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

* fix(codex): keep compaction usage on one thread

A child rollout imported a replayed remote-compaction record again, and a usage line scanned before its confirming token count was dropped on the next pass. The event now uses the confirming line, and a replayed confirmation is skipped.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

---------

Co-authored-by: funkpopo <funkpopoisme@gmail.com>
Co-authored-by: chonngyi <111365239+chonngyi@users.noreply.github.com>
Co-authored-by: Chaoqi <136579807+Chaoqi31@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>
Chris-Xie369 added a commit to Chris-Xie369/cc-switch that referenced this pull request Oct 22, 2026
* fix(tray): keep tray panel rows stable and fit scaled displays (farion1231#8572)

Size the provider column to the main panel's height and scroll long lists inside it, so hovering between apps no longer shifts rows under the cursor. Convert between WebView CSS pixels and screen logical pixels using the monitor scale factor on Windows, narrow and clamp the panel to the monitor work area, and let footer buttons shrink. On Windows, left-click on the tray icon now always opens the main window; right-click shows the selected tray style.

Fixes farion1231#8553
Fixes farion1231#8549

* fix(tray): refresh DPI coordinates after cross-monitor show (farion1231#8595)

* fix(windows): remove stale portable marker during MSI installation (farion1231#8529)

* fix(usage): count Codex compaction usage from token_usage_record (farion1231#8479)

* fix(provider-form): stop macOS from capitalizing typed API keys

The API key field is a plain input, so it missed the autocorrect,
auto-capitalization and spellcheck opt-outs the shared Input component
already sets. With the key shown in plain text, a typed "sk-..." key
could be saved as "Sk-..." and fail authentication. Pasted keys were
not affected.

* fix(tray): align quota bars and show time until reset

Lay out each quota block as one grid (label | bar | reset) so bars start
and end at the same x across tiers, and let the quota row extend under
the chevron / "In use" column so bars are no longer squeezed. Replace the
absolute reset date with a compact time-until-reset (at most two units,
e.g. "1d 23h"), with unit strings added to all panel text branches, and
keep quota labels from wrapping.

* feat(tray): mark the balance amount in panel quota lines

Carry an `emphasis` field on tray quota lines and the panel payload,
set to the amount only when a pay-as-you-go balance is left (matching
the card's `balanceLine`), so the panel can color just the number.

* feat(tray): show remaining balances in green in the tray panel

Color only the balance amount green, like the provider card's quota
line, while the label stays muted and a used-up balance keeps its
warning color.

* fix(proxy): stop child processes from inheriting the listener socket on Windows

On Windows the proxy port could stay LISTENING after CC Switch exited,
owned by the PID of the dead process, so the next start failed to bind
with os error 10048 and fell back to direct mode until a reboot or a
port change.

The listener comes from tokio -> mio, and the locked mio 1.1.1 created
Windows sockets with plain socket(), which yields an inheritable handle.
CC Switch spawns long-lived children (terminals via `cmd /c start`, CLI
tools), and std's Command passes inheritable handles to them, so those
children kept the listening socket open after CC Switch was gone.

Bump mio to 1.2.1, which creates sockets with WSA_FLAG_NO_HANDLE_INHERIT
(tokio-rs/mio#1946), and add a Windows-only test asserting the listener
handle is not inheritable so the lockfile cannot silently regress.

Refs farion1231#8552

* fix(webdav): upload the sync manifest as application/octet-stream

CSTCloud Data Capsule's WebDAV server answers PUT manifest.json with
500 "Uploaded object size does not match the upload session" whenever
the body is multi-line JSON sent as application/json. The same pretty
JSON is stored byte-for-byte when sent as application/octet-stream, and
db.sql / skills.zip already upload fine with their own types.

Send the manifest with application/octet-stream. The manifest bytes and
hash stay the same and readers never look at Content-Type, so old and
new versions keep reading each other's manifests. S3 sync is unchanged.

Refs farion1231#8554

* fix(toml): only normalize smart quotes used as string delimiters

normalizeTomlText replaced every curly/fullwidth quote in the text before
TOML parsing, including quotes inside already-valid strings. MCP import and
validation (tomlUtils, mcpDraft) and the Codex config editor therefore
silently rewrote values such as args = ["don’t"] to "don't", and rejected
valid configs like args = ["say“hello”"] or args = ['don’t'] because the
inner quotes became delimiters.

Scan the text instead: smart quotes outside strings are delimiters and
become ASCII; strings opened by an ASCII quote keep smart quotes as
content; strings opened by a smart quote may close with either form;
multi-line strings and comments are handled. To keep the Chinese IME case
working (the editor normalizes each keystroke, so the left quote is already
ASCII when the right smart quote is typed), an ASCII-opened string left
unterminated at end of line is re-scanned with smart closers, and if the
strict result still fails to parse, a lenient scan is used only when it
parses. Escaped smart double quotes in basic strings become \". Text
without smart quotes is returned unchanged.

Refs farion1231#8559

* fix(codex): don't give an expired stashed login back to the proxy

Since farion1231#8431, entering or attaching the proxy for a third-party Codex
route gives back the most recently stashed ChatGPT login when auth.json
has none. The stash can hold a login set aside months ago by a direct
switch with "keep official login" off. Restoring it made the route
table require OpenAI auth, Codex desktop tried to refresh the dead
token and got stuck on the sign-in page, and every exit/enter put it
back again.

The restore now skips a login whose access_token JWT has already
expired. Codex refreshes the token every 8 days and it lives about 10,
so an expired one means the login has not been used for a long time.
Tokens that cannot be decoded are still restored, and the skipped login
stays in the stash so switching back to an official card returns it.

Refs farion1231#8573

* fix(mcp): project url-only servers without a type to Hermes and OpenCode

A unified MCP spec can lack a `type` field when it was pasted in JSON
mode or created via a deeplink, e.g. `{"url": "https://..."}`. Hermes'
and OpenCode's projections defaulted a missing type to stdio, whose
branch only copies command/args/env. Hermes therefore got an entry with
nothing but `enabled: true`, and OpenCode got `type: local` with an
empty command, while the UI reported a successful sync.

Codex's projection already handled this in `spec_transport_type`
(missing or stdio type, no command, non-empty url => http). Move that
helper unchanged into `mcp/validation.rs` and use it from the Codex,
Hermes and OpenCode projections, so all three infer the transport the
same way. Add regression tests for url-only and command-only specs.

Refs farion1231#8579

* fix(mcp): take the app's switch lock before toggling a server

toggle_mcp_app and set_mcp_enabled called McpService::toggle_app without
the per-app switch lock. Each single-server write reads the app's live
config, edits it and writes it back, so two toggles in flight at once (or
a toggle racing a provider switch, a proxy enter or an MCP resync, which
all hold that lock) could overwrite each other and silently drop servers
from ~/.claude.json, config.toml and friends while the database still
showed them enabled.

Add McpService::toggle_app_locked, which takes the same switch lock
resync_mcp_to_apps already uses and then runs toggle_app, and route both
commands through it. toggle_app itself stays lock-free because the tokio
lock is not reentrant and internal callers may already hold it; MiniMax
Code and Pi keep their own file lock inside sync_and_commit, which never
takes the switch lock. A test fires 16 concurrent toggles at Claude and
checks every server lands in the live file (it fails without the lock).

Refs farion1231#8580

* fix(tray): release the tray panel WebView in lightweight mode

Lightweight mode frees memory by destroying the main window's WebView.
Since the tray panel became the default tray UI, the panel is a second
WebView window that is created on the first tray click and afterwards
only hidden, so a WebView stays alive and on Windows the WebView2
processes never exit after entering lightweight mode.

In lightweight mode the panel is now destroyed whenever it is hidden
(and right away when entering lightweight mode while it is already
hidden); the next tray click creates it again. The destroy is deferred
to a later main-thread turn and re-checks that the panel is still hidden
and not about to be revealed, so it never tears the WebView down inside
its own window-event callback or IPC command. toggle() decides "close"
before creating the window so a click that only closes the panel does
not recreate it, and a recreated window starts with a fresh theme state
and, on macOS, fresh glass views.

Refs farion1231#8612

* feat(hermes): warn when a provider key is a Hermes built-in provider id

Hermes routes model.provider to its built-in provider whenever the name is a
canonical built-in id (runtime_provider_custom._shadowed_by_builtin), so a card
keyed "deepseek" or "openrouter" is ignored: Hermes asks for the built-in's own
API key env var and never uses the card's endpoint or key. farion1231#8538 only fixed the
two MiniMax presets by prefilling cc-switch-minimax.

The provider key field now shows a warning for any of the 52 canonical ids
(generated from hermes-agent rc.3-v0.22.0) and suggests cc-switch-<key>. A
locked key gets a variant that says to re-add the provider under another key.
Aliases such as kimi still reach the custom provider and are not flagged.
Saving is not blocked.

* chore(release): v4.0.17

* docs(release): add v4.0.17 release notes

* feat(agy): integrate Antigravity CLI management (farion1231#8268)

Add Antigravity CLI (agy) as a managed app: provider switching for the native login and API-key modes, MCP and Skills sync through ~/.gemini/config, session browsing and session-log usage import.

Co-authored-by: peterLwk888 <lwk1124@outlook.com>

* refactor(agy): launch API-key providers from the terminal instead of a shell wrapper

agy only reads GEMINI_API_KEY and GOOGLE_GEMINI_BASE_URL from the process
environment, and modelProvider is global. The previous approach wrote the key
to a file, installed a wrapper script and edited .zshenv/.zprofile/.zshrc so
every agy picked it up.

Now the native settings.json always stays in login mode: login cards set the
model (display name) and drop modelProvider; key cards only drop
modelProvider. A key card applies to agy started from the card's "Open
Terminal": `agy --gemini_dir=<private dir>` under ~/.cc-switch/agy/<id>, whose
settings carry modelProvider and the model and whose config links back to
~/.gemini/config so MCP, Skills and prompts stay shared. The key and endpoint
reach that one process through the environment only.

The terminal launcher now takes a per-app launch description (command, env,
temp files) so Claude Code and agy share the terminal layer. On macOS/Linux
env values are read from a 0600 file that is deleted right away and passed
via `env`, so values exported by the user's rc files cannot override them;
on Windows they live in a setlocal block. Claude's temporary settings file is
now written 0600 as well, and temp files are removed before handing over to
the interactive shell (exec skipped the EXIT trap).

On startup, wrappers, env files and PATH blocks left by earlier test builds
are removed (only CC Switch-marked content) and the current provider is
reapplied so modelProvider returns to login mode.

* feat(agy): show the model on cards and attribute session usage to it

Ported from farion1231#8608:
- Model discovery works before agy has written a settings.json; the
  official login lives outside that file.
- Configurations imported from live are marked official: the key is never
  on disk, so an import is always agy's own login.
- Antigravity provider cards show the configured model.
- Session usage rows take their model from the `Model Selection` step agy
  injects at the start of a conversation, since responses carry no model.

Not ported: running `agy models` while importing on startup or when the form
opens. It takes several seconds on a signed-in machine and may prompt for
sign-in on one that never logged in; the Fetch Models button stays explicit.

Co-authored-by: ajia1206 <491387123@qq.com>

* feat(agy): share prompts with key profiles, resume key sessions, price usage

- Store the agy prompt in ~/.gemini/config/AGENTS.md, which agy reads as a
  standalone customization root and every private key profile links to.
- List sessions recorded under each key profile and resume them with the
  matching --gemini_dir and the card's key in a launched terminal.
- Fetch the model list for API-key cards from agy's own offline catalog in a
  throwaway profile, so the form offers display names agy actually accepts.
- Record agy session usage under priced model IDs (display name kept as the
  request model) and estimate cost from Gemini API prices.
- Treat an imported settings file with a model as the official login card.

* feat(agy): show the signed-in quota on the official card

- Ask agy's own /usage command (agy -p /usage --output-format json) instead of
  reading credentials; it makes no model call and opens no conversation.
- Keep the Gemini group and the Claude/GPT group, each with its 5-hour and
  weekly window, as four tiers; unknown buckets are skipped.
- On the card each group shows only its window with the least left, since
  four tiers do not fit; the expanded view lists all four.
- A missing agy is treated as no login (nothing shown); a timeout propagates
  so the last good value is kept; other failures do not echo agy's output.

* fix(agy): show only the typed prompt in sessions and describe the quota check

- agy stores every prompt wrapped in <USER_REQUEST>, followed by context it
  adds itself (<ADDITIONAL_METADATA>, <USER_SETTINGS_CHANGE>). Session titles
  and user messages now show only the text inside <USER_REQUEST>; an empty
  request keeps the original content.
- The official-quota hint said CC Switch reads the CLI's OAuth credentials.
  For agy it runs agy's own /usage command instead, so the usage dialog now
  says that (and that a check takes about 10 seconds).

* feat(agy): give Antigravity its own icon and manage the CLI on the Apps page

- Use the Antigravity logo for the app instead of reusing Gemini's.
- List Antigravity CLI on the Apps page: install with the official script,
  update with `agy update` (also for the Homebrew cask, which is
  auto_updates), read the latest version from the release manifest, and
  uninstall official-script and cask installs.
- Find install.ps1's %LOCALAPPDATA%\agy\bin on Windows.

* fix(agy): accept the empty mcp_config.json agy creates on first run

agy writes a zero-byte ~/.gemini/config/mcp_config.json the first time it
starts. Parsing it as JSON failed, so enabling any MCP server for
Antigravity showed "Invalid Antigravity MCP JSON" and importing from it
failed too. Treat a blank file as having no servers.

* feat(agy): replace the enable button with Open Terminal

CC Switch can only reach the agy it starts itself: a third-party key
travels in that process's environment, so "enabling" a key card never
changed what a plain `agy` used. Antigravity cards now only launch.

- Every card's primary action is Open Terminal; there is no "in use"
  state, the official quota shows on official cards at all times, and
  any card can be deleted.
- Official cards start `agy --model=<display name>`, which applies to
  that session only. Key cards keep their private --gemini_dir.
- The real ~/.gemini/antigravity-cli/settings.json is never written any
  more: switching is refused, adding the first card, full syncs and
  deep links with enabled=true leave it alone. The only remaining write
  removes the modelProvider left behind by the farion1231#8268 preview.
- The terminal dialog and form hints no longer talk about switching.

* feat(agy): trim the form hint and require a key on custom cards

The long paragraph above the Antigravity form explained a case that should
not exist: a non-official card without a key is just the official login
card again. Drop the paragraph and warn on save when a custom card has no
API key, as Gemini CLI already does. A key without an endpoint stays valid
and uses Google's default API.

The endpoint and model rules move into "?" tips next to their fields. The
card's terminal button now reads "Open Terminal with This Provider"; the
shorter "Open Terminal" label stays for the Claude Code card menu.

* fix(windows): stop in-app updates from rebooting the machine (farion1231#8628)

The MSI now carries REBOOT=ReallySuppress, so Windows Installer never restarts the machine when a file is in use during an in-app update, including updates driven by older updaters. The updater also passes REBOOT=ReallySuppress, a leftover instance is closed on the updater path, and the release workflow fails if the built MSI lacks the property.

Fixes farion1231#8534

* fix(ui): show compact mode-colored connection badge in header (farion1231#8634)

* fix(ui): trim redundant provider and settings helper copy (farion1231#8633)

* fix(installer): make Windows desktop shortcut optional (farion1231#8601)

Add a "Create a desktop shortcut" checkbox to the MSI install-directory page and a public CREATEDESKTOPSHORTCUT property for silent installs. Upgrades keep the current state of the desktop shortcut, and clearing the option during an upgrade removes the old one.

The desktop shortcut is now copied from the Start Menu shortcut by custom actions with Return="ignore", so a Desktop that cannot be written no longer blocks or rolls back the install.

Fixes farion1231#5667

Co-authored-by: thefool <thefoolyuzhe@gmail.com>

* docs(aggregation): correct the workaround for the exhausted-quota lock

Since route and aggregation mode keep the ChatGPT login (farion1231#8431), the
route table writes requires_openai_auth = true whenever a login is on
disk, so making a third-party card the aggregation default no longer
keeps Codex from locking the send button to Luna Reserve once the
official 5-hour quota runs out.

The guide and the 4.6 manual pages now describe the method that still
works: switch to Direct, keep "Keep official login for direct switches"
off, switch to a third-party card and fully restart Codex; switch back
to Aggregation once the quota resets. The old method is marked as no
longer working.

Refs farion1231#7962

* fix(usage): only list apps shown on the Apps page in the app filter

The usage dashboard listed every known app as a filter chip regardless of
the visibility switches on the Apps page. Filter the chips the same way the
session manager does: Claude Code stays while Claude Desktop is shown, and
the currently selected app is kept even if it has been hidden.

* feat(agy): manage Antigravity official accounts in the Auth Center backend

agy keeps a single global login in the system keychain (service "gemini",
account "antigravity"). CC Switch now keeps its own copy of each account
and switches agy by writing the chosen one back into that entry.

- Sign in through the browser with agy's own OAuth client (PKCE, loopback
  callback on 127.0.0.1), requesting the same scopes agy does, so agy can
  keep refreshing the token on its own.
- Read and write the keychain through /usr/bin/security, the program the
  entry already trusts: the value goes over `security -i` stdin, `-U`
  updates in place and keeps the entry's access control, and the write is
  read back and checked.
- Before overwriting, take back the current login: refresh tokens agy
  renewed go to their account, an unknown account is added, and an
  unreadable value is stashed to agy-login-stash.json.
- Removing an account only drops CC Switch's copy; agy is never signed out.
- Exposed as the "agy_oauth" provider of the generic auth_* commands, where
  the default account is the one agy is signed in with. macOS only for now.

* feat(agy): add an Antigravity group to the Auth Center

On macOS the Auth Center now lists Antigravity accounts next to Copilot,
ChatGPT and xAI. The account agy is signed in with is tagged "In use by
agy", and "Switch agy to this account" in the row menu replaces agy's
sign-in. After switching, CC Switch checks whether agy is running and
warns that open sessions keep the old account and may write it back.

Adding an account opens Google's sign-in page in the browser. The shared
login hook treats a response without a user code as this browser flow: it
leaves the clipboard alone, polls the local result every 1.5 seconds and
cancels the backend flow so the callback port is released. The pending
block shows a "reopen sign-in page" button instead of a device code.

Removing an Antigravity account says that agy stays signed in.

The backend now records the sign-in time in seconds, which is what the
Auth Center formats.

* feat(agy): bind official cards to an Antigravity account

An official Antigravity card can now pick one of the Auth Center accounts
(macOS only). With an account picked, Open Terminal first switches agy's
global sign-in to it; when it is already signed in, nothing is written.
Without one, the card keeps following whatever agy is signed in with.

The binding is stored as authBinding with authProvider "agy_oauth". The
account id is the Google subject, so a card synced to another computer
works once the same account is added there; if it is missing, opening the
terminal stops with a message instead of launching under another account.
Platforms that cannot switch ignore the binding and keep it on the card.

The Open Terminal dialog says when opening will switch agy's sign-in, and
warns when agy is already running. The Auth Center counts bound cards as
users of an account, so the removal dialog lists them.

* fix(pricing): price deepseek-v4.1-flash as V4.1 Flash

Third-party upstreams such as OpenCode Go, OpenRouter (deepseek/
namespace) and the Hermes presets call DeepSeek V4.1 Flash
deepseek-v4.1-flash. Pricing had no row for that id: none of the lookup
fallbacks turn it into deepseek-flash, and the prefix fallback only
matches longer ids. These requests showed "Unpriced" and were counted
at $0.

Add a deepseek-v4.1-flash row at the same official peak price as
deepseek-flash (0.3 / 1.2 / 0.006 per million). Default prices are added
again on every start, so existing installs get the row on next launch.
Requests already logged are not repriced.

* feat(agy): query each Antigravity account's quota directly

The Auth Center can now show quota for every Antigravity account, not
only the one agy is signed in with. The new agy_account_quota command
calls Google's retrieveUserQuotaSummary with the account's own token, so
no switch is needed. The response carries the same buckets as agy's
/usage, and both now go through one tier builder.

The endpoint only answers clients that identify as Antigravity, so the
User-Agent is "cc-switch/<version> (antigravity)": it names CC Switch
and adds the word the server checks for. It only reads quota; it never
calls a model, warms up accounts or switches on low quota.

Tokens that expire within two minutes are refreshed first. The refreshed
token is saved only to CC Switch's copy, never to the keychain: agy keeps
refreshing its own login, and Google does not rotate the refresh token,
so both access tokens stay valid. For the account agy is using, the
keychain login is absorbed first so its newer token is used. A rejected
token triggers one forced refresh; invalid_grant marks the account as
needing sign-in again.

* fix(agy): explain why an account is not eligible for Antigravity

Accounts that Google does not allow to use Antigravity, such as those
restricted by age, get the same 403 "You do not have a valid license,
contact your administrator" from the quota endpoint. That reads like a
CC Switch or licensing problem. On a 403 the quota query now asks
loadCodeAssist once, and when the account only has ineligible tiers it
shows Google's own reason instead.

* feat(agy): show each account's quota in the Auth Center

Every Antigravity row in the Auth Center now has a quota column, the
same one Copilot, ChatGPT and xAI rows use: Gemini and Claude/GPT, each
with a 5-hour and a weekly window. It is queried once when the page
opens and on the refresh button, through agy_account_quota, so accounts
agy is not signed in with show quota too. Accounts that need to sign in
again get no quota column.

* perf(agy): query the signed-in account's quota without starting agy

Official Antigravity cards asked agy's own /usage command for quota, which
starts agy and takes seven to nine seconds. On macOS the card now takes the
account agy is signed in with from the keychain, adds it to the Auth Center
if it is new, and asks Google directly with CC Switch's copy of the token,
the same way the Auth Center does (about one second).

agy's /usage is still used when the keychain holds no recognizable login,
when the user removed that account from CC Switch, and on platforms that
cannot switch accounts.

* feat(agy): show the bound account's quota on official cards

An official Antigravity card bound to an Auth Center account showed the
quota of whatever account agy was signed in with, which is a different
account until the card's terminal is opened. It now shows the bound
account's quota, shared with that account's row in the Auth Center, and
like Codex account cards it shows even when the card is not current.

Cards that follow agy's sign-in, and platforms that cannot switch
accounts, keep the signed-in quota.

* feat(agy): open all four quota windows from the card's quota column

The card keeps one line per group (Gemini, Claude/GPT) to fit, so the
5-hour and weekly windows could not both be seen anywhere on it. On
Antigravity cards the quota column is now one dropdown, like the resets
dropdown on Codex official cards, listing every window with its own
reset time. Refreshing moves to the ↻ button that appears on hover.
Other cards keep click-to-refresh.

* fix(agy): drop the Antigravity-only note on the usage page

No other app shows a pricing note above the usage tables, although
Codex, Gemini and the rest also estimate session-log amounts from API
prices. Remove the note, its locale key and the test that pinned it.

* fix(agy): show Antigravity session-log rows as "Session log"

The frontend map of session placeholder names missed
"Antigravity (Session)", so the provider column showed the raw English
name and truncated it. Add the entry and a test that every placeholder
name in usage_stats.rs is translated.

* feat(presets): promote EveryAPI to a full sponsor

EveryAPI moves from the basic tier (in-app presets and website only) to
a full sponsor, placed directly below OpenModel in the sponsor order.

- Four-locale README sponsor rows with the new copy and a 1200px banner.
- The EveryAPI preset blocks in the seven preset files move below
  OpenModel so file order matches the README tables; the presets
  themselves are unchanged.
- The in-app promo copy adds "GPT from 15% of the official price" and
  folds the first top-up bonus and the "ccswitch" code into one perk,
  since they are the same offer.

* chore(presets): remove ZetaAPI sponsor and presets

ZetaAPI is no longer a sponsor; its presets go too.

- Drop the four-locale README sponsor rows and the banner.
- Remove the ZetaAPI presets from Claude Code, Claude Desktop, Codex,
  Grok Build, OpenCode, OpenClaw, Hermes and Pi.
- Remove partnerPromotion.zetaapi from every locale, Turkish included.
- Keep the zetaapi icon: existing providers saved from the preset still
  reference that icon key.

* feat(presets): add DIT.ai sponsor presets across nine apps

DIT.ai (dit.ai), an AI token exchange routing each request across
competing providers, joins as a sponsor, placed directly below GrooRoute
Pro in the sponsor order (OpenModel in the Gemini file, which has no
GrooRoute preset).

- One key on api.dit.ai serves Chat Completions, Responses, Anthropic
  Messages and native Gemini (protocol docs at dit.ai/docs, catalog at
  dit.ai/models, checked 2026-10-21).
- The gateway has no Haiku 5.5 and only the dated
  claude-haiku-4-5-20251001, so Claude Code overrides the Haiku tier with
  it and Claude Desktop maps Sonnet 5.5 / Opus 5.5 / that Haiku.
- OpenCode, OpenClaw, Hermes and Pi use Anthropic Messages with Claude
  Opus 5.5 and Sonnet 5.5; Codex uses Responses with gpt-5.6-sol, Grok
  Build grok-4.7, Gemini CLI gemini-3.8-flash.
- Four-locale README sponsor rows with a banner, four-locale promo copy
  with the "ccswitch" coupon, and an inline icon from the official logo.
- Claude preset rows in the selector test go from 105 to 106.

* feat(presets): add hao.ai sponsor presets across eight apps

Add hao.ai as a full sponsor placed above Fenno: README rows and banner
in all four languages, the partner promotion copy, an inline tile icon,
and presets for Claude Code, Claude Desktop, Codex, Grok Build, OpenCode,
OpenClaw, Hermes and Pi. hao.ai serves Anthropic Messages under
/anthropic, OpenAI-compatible APIs under /v1 and xAI under /x-ai/v1, and
its catalog IDs carry a provider prefix. There is no Gemini preset since
the catalog has no Gemini models.

* fix(codex): hide reset countdown for unused quota windows (farion1231#8631)

* fix(sessions): group a Claude session by its last /cd location (farion1231#8643)

* fix(terminal): stop waiting for the launched console to close on Windows

Opening a terminal on Windows ran `cmd /C start ...` through
Command::output(). The console that `start` opens inherits the wrapper's
stdout/stderr pipe, so the pipe only reached EOF once the user closed
that window, and the command (and the open-terminal dialog) kept
spinning until then.

Point stdio at NUL and wait only for the wrapper's exit status. Launch
failures still surface through the exit code. Same approach as the
community PR farion1231#6716 by @umutcankurt, applied to the current code path.

Refs farion1231#6712

* fix(apps): give runtime CLI commands the login-shell PATH on Unix

When CC Switch is launched from Finder, its PATH is launchd's narrow
/usr/bin:/bin:/usr/sbin:/sbin. run_detected_tool_command_with_timeout
located the tool through a login shell but then spawned it with only the
tool's own directory prepended to that narrow PATH. An npm-installed CLI
is a `#!/usr/bin/env node` script, so when node lives in a different
directory (codex in /opt/homebrew/bin, node in /usr/local/bin) the
"Restart Codex daemon" action failed with `env: node: No such file or
directory`. The same helper runs `opencode models`.

Merge the login-shell PATH in front of the inherited PATH, as tool
install/update already does. login_shell_path now honours the caller's
deadline and spawns like resolve_path_default (stdin null, own session);
when it fails or times out the previous PATH is used unchanged.

Refs farion1231#8653

* fix(app): raise the open-file soft limit at startup on Unix

macOS GUI apps started from Finder or a login item inherit launchd's
soft RLIMIT_NOFILE of 256. The local router holds a client socket and an
upstream connection per in-flight request on top of the log, database
and config files, so a burst of parallel requests exhausted the
descriptors: accept() failed with EMFILE, upstream connects failed,
reading live-state.json failed and SQLite could not open the database.

Right after the logger is initialized, lift the soft limit to the hard
limit capped at 10240 (macOS OPEN_MAX; setrlimit rejects larger values
there). The hard limit is never touched and a limit that is already high
enough is left alone; failures are only logged.

Refs farion1231#8668

* chore(release): v4.0.18

* docs(release): add v4.0.18 release notes

* docs(upstream): record the v4.0.18 sync

The ledger now states the v4.0.17 to v4.0.18 merge, the conflict resolution, and the schema 21 migration.

Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>

---------

Co-authored-by: funkpopo <funkpopoisme@gmail.com>
Co-authored-by: chonngyi <111365239+chonngyi@users.noreply.github.com>
Co-authored-by: Chaoqi <136579807+Chaoqi31@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: Owlbay <gzh298255@gmail.com>
Co-authored-by: peterLwk888 <lwk1124@outlook.com>
Co-authored-by: ajia1206 <491387123@qq.com>
Co-authored-by: Thefool <thefoolyuzhe@gmail.com>
Co-authored-by: SaladDay <1203511142@qq.com>
Co-authored-by: cuiyf5516 <48861298+cuiyf5516@users.noreply.github.com>
Co-authored-by: Dopetaiga <120038262+Dopetaiga@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Chris-Xie369 <Chris-Xie369@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend (Rust/Tauri)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants