tokenez
Tokenez — App Bridges
Local capture engines that track real, live app-side token/quota usage for tools the browser extension can't see into (desktop IDEs, CLIs).
The bridge is the hub. Every event — from a local engine or pushed
down by the browser extension's site capture — is aggregated and persisted
by lib/store.js (hub-state.json, gitignored), and served by
lib/http-server.js at http://localhost:5175 (loopback only):
GET /api/data— live snapshot in the dashboard's data shapeGET /— the built dashboard itself (dashboard/dist), so the whole app runs with no extension installed and no Chrome open. The extension is an optional add-on that captures browser AI sites and relays them to the bridge; it's never required and never needs Web Store publishing.
Payload types engines can broadcast (tokens and quota-% are deliberately separate units — the dashboard never mixes them):
TOKENWRAP_USAGE—{model, source, inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, timestamp, accuracy}TOKENWRAP_QUOTA_SNAPSHOT— absolute plan state, rendered as ring gauges:{source, meters: [{label, usedPercent, resetsAt?}], timestamp}(Windsurf: Daily/Weekly windows; Antigravity: per-model quotas)TOKENWRAP_QUOTA_DROP—{source, consumedFraction, timestamp}deltas, accumulated intoquotaStatsTOKENWRAP_SESSION—{source, durationSeconds, timestamp}(extension)
Each engine lives in lib/ and exports a start(broadcastPayload) function.
lib/ws-hub.js provides that broadcastPayload — whichever engine process
starts first binds the WebSocket port (ws://localhost:5174) for real; every
other one (run standalone, or another engine in the same run.js process)
detects the port is taken and relays through it instead, so you never get a
port conflict no matter how many of these you run at once. Only the process
that owns the port writes the store and serves HTTP, so events are recorded
exactly once. The hub also still forwards engine events to the extension
(for its popup) and never echoes an extension-pushed event back to it.
Install (end users)
The bridge is packaged as an npm CLI (package.json name: tokenez,
bin: tokenez). Node ≥ 22.5 is the only prerequisite (node:sqlite).
Published to npm (tokenez, 0.2.3+ as of 2026-07-18). To cut a new release:
npm publish from bridge/; the prepack script bundles dashboard/dist
into the package automatically, so build the dashboard first.
npm install -g tokenez # one-time install
tokenez # run it → open tokenez.tech/app
tokenez autostart on # start hidden at every login (off to remove)
tokenez autostart writes a Startup-folder .vbs on Windows (hidden window,
verified) and a LaunchAgent plist on macOS (written but not live-verified —
no Mac here); Linux gets printed instructions. The plain tokenez name on
npm is taken by an unrelated package — don't publish under it.
For non-Node users there's a standalone Windows tray build
(scripts/build-exe.js → dist-exe/tokenez.exe + traybin/ +
tray-icon.ico), distributed as a zip from the tokenez-releases repo. It
is unsigned — it trips SmartScreen, and code-signing certificates cost
real money — so npm remains the friction-free path for anyone who has Node.
Tray app: startup failure modes (verified 2026-07-25)
Two bugs made a failed tray launch look like a permanently broken app, and both are worth knowing because the symptom is so misleading: the exe runs, nothing appears, and double-clicking it again does nothing at all.
build-exe.jscopiesnode.exe, a console-subsystem binary, but the shipped exe is launched from Explorer with no console attached — so everyconsole.log/console.errorintray-app.jswrote to nowhere. A failedsystray.ready()therefore produced no visible error of any kind.- On that failure the process used to stay alive with engines started and
the hub port bound. Because
tray-app.jsexits when it detects another instance owns the port, that zombie then silently swallowed every later launch — leaving Task Manager as the only recovery. (Reproduced by a user on two separate laptops, first run only, on both.)
Fixed by: mirroring all tray logging to %LOCALAPPDATA%\Tokenez\tray.log
(the console does not exist, so a file is the only durable record); exiting
with code 1 when systray.ready() rejects, so the port frees and a simple
relaunch recovers; and surfacing both the duplicate-instance case and the
tray-failure case as native message boxes, since a GUI-less exe otherwise
has no way to tell the user anything.
Unconfirmed root cause for why it only ever fails on the first run:
the tray helper (traybin/tray_windows_release.exe) is a second unsigned
binary freshly extracted from the downloaded zip, so it carries
Mark-of-the-Web and gets scanned by Defender on first execution; once
Windows has ruled on it the verdict is cached. This fits the observed
"fails once, then fine forever after a kill + relaunch" behaviour, but has
not been verified on a clean machine. The new log records cwd on every
launch, which would also catch the other candidate — systray2 resolves
./traybin/<binary> relative to the process CWD, not the exe path, so
any launch context where those differ would fail the same way.
Run it (repo checkout)
node run.js # every engine, one process (recommended)
node antigravity-bridge.js # just Antigravity
node claude-code-bridge.js # just Claude Code CLI
Leave it running while you use the apps. An engine that finds nothing to watch (app not installed, or Windows-only engine on another OS) just logs that and stays idle — it won't crash the others.
run.js refuses to start if another bridge already owns the hub port
(exits with a message after ~2s) — two full engine sets against the same app
files would ingest every event twice. This was one cause of the "inflating
totals" seen 2026-07-15/16. Relay mode remains only for the single-engine
standalone scripts.
To serve the dashboard from the bridge, build it once:
cd dashboard && npm run build (the API works regardless).
Claude Code CLI engine (lib/claude-code-engine.js)
Claude Code writes one append-only JSONL transcript per session to
~/.claude/projects/<project>/<sessionId>.jsonl (subagent runs get their own
file under a subagents/ subfolder). Every assistant turn logs the model and
message.usage (input_tokens, output_tokens, cache_creation_input_tokens,
cache_read_input_tokens) directly — no network capture needed, and it's
fully cross-platform since it's just fs/chokidar, no shelling out.
- Token semantics (fixed 2026-07-16):
inputTokens=input_tokens+cache_creation_input_tokens— tokens processed fresh this call.cache_read_input_tokensis reported separately ascacheReadTokens, never added to input. Every tool-call round-trip is its own API call that re-reads the entire conversation from prompt cache; counting those reads as input re-counts the whole context per turn and inflates a session quadratically (observed: 6.2M "input" in 10 minutes that was ~98% cache reads of the same ~330K context — real fresh input wasinput_tokens: 2per call). Log entries before 2026-07-16 use the old inflated semantics. - Watches
~/.claude/projectsrecursively and tails new bytes as files grow. - Dedupes per message id (a turn's repeated content-block lines all carry the same final usage).
- A file seen for the first time is baselined at its current size rather than backfilled, so a fresh bridge run doesn't count pre-existing history as one giant spike.
- State:
claude-code-state.json(byte offset per file). Log:claude-code-usage-log.jsonl.
Antigravity IDE engine (lib/antigravity-engine.js)
Polls Antigravity's own local GetUserStatus endpoint to track real, live
per-model quota consumption — no estimation, no log parsing. Windows only
(PowerShell-dependent process/port discovery) — skips itself on other
platforms.
How it works
- Finds the running
language_server_windows_x64.exeprocess (the account-level one, without--workspace_id) - Extracts its
--csrf_tokenfrom the process command line - Finds which port it's actually listening on right now (ports and tokens change every time Antigravity restarts — this is handled automatically)
- Calls
GetUserStatusover local HTTPS every 60 seconds - Diffs each model's
remainingFractionagainst the last poll - Logs any consumption to
usage-log.jsonl
It reconnects automatically if Antigravity restarts (new PID/port/token).
Output
Console, live:
[2026-07-10T14:32:01.000Z] Usage detected:
Claude Sonnet 4.6 (Thinking): consumed 0.530% of quota
Claude Opus 4.6 (Thinking): consumed 0.530% of quota
usage-log.jsonl — one JSON line per poll where something changed:
{"timestamp":"2026-07-10T14:32:01.000Z","changes":[{"model":"Claude Sonnet 4.6 (Thinking)","consumedFraction":0.0053}]}
bridge-state.json — last known snapshot, so restarting the script doesn't
lose your baseline.
Known limitations
- Windows only (PowerShell-dependent). A macOS/Linux version would need
lsof/psequivalents instead. - Gives you % of quota consumed, not raw token counts — Antigravity
doesn't expose an explicit per-model token ceiling, only a shared
monthlyPromptCredits/monthlyFlowCreditspool at the account level. - Sonnet and Opus appear to share one quota pool (they moved identically in testing) — don't treat them as independently tracked.
- If Antigravity isn't running, the bridge just waits and retries — it doesn't crash.
Accuracy field
Bridge payloads carry an accuracy field, "exact" or "estimated", that
the extension stores on each event and the dashboard renders as an exact/est
badge (dashboard/src/components/Feed.jsx). The rule: only a source that reads
real server-reported token counts says "exact" (Claude Code, Antigravity's
token engine, Cursor when tokenCount is populated); anything derived from
js-tiktoken text estimation says "estimated". The worker defaults a missing
value to "estimated" — the honest default — so never mark a source exact
unless it truly is.
App engines: implemented + next
Display metadata for these lives in dashboard/src/lib/registry.js (Cursor,
Windsurf, Zed, JetBrains, VS Code). Windsurf and Cursor now have working
engines (below); the rest are still unwired.
Windsurf (now "Devin Desktop") — ENGINE BUILT:
lib/windsurf-engine.js(quota % only). Signal verified-live on a real install 2026-07-15. The original "just generalizePROCESS_NAME" plan is dead, but one clean local signal works. Findings:- Cognition acquired Windsurf in 2025 and shipped an OTA rebrand to Devin
Desktop (2026-06-02). On disk it installs as
Devin.exe, data under%APPDATA%\Devinand~\.codeium\windsurf; bundled LS is stillextensions\windsurf\bin\language_server_windows_x64.exe. - THE SIGNAL — one SQLite key, plain JSON, confirmed to move with usage.
%APPDATA%\Devin\User\globalStorage\state.vscdb(VS CodeItemTablekey/value store), keywindsurf.reactSettings.cachedPlanInfoData:(note the trailing colon). Value is plain JSON — no protobuf, no decryption:{planName, billingStrategy:"quota", remainingMessages, totalMessages, dailyRemainingPercent, weeklyRemainingPercent, remainingFlexCredits, overageBalanceMicros, dailyResetAtUnix, weeklyResetAtUnix, ...}. Verified-live: after sending prompts in Devin,dailyRemainingPercentwent 99→98 andweeklyRemainingPercent100→99, exactly matching Devin's own in-product Usage panel (2% daily / 1% used). Engine = read this key on an interval via built-innode:sqlite(Node 22+,DatabaseSync,readOnly:true),JSON.parse, diff the percents, broadcast a quota-drop payload likelib/antigravity-engine.js's quota half does. Zero new deps. - Dead ends ruled out (don't re-try these):
- Live LS HTTP poll (Antigravity-style) — LS listens on plain HTTP
(
--server_port, e.g. 55473) butGetUserStatusreturns401 missing CSRF token, and the token is no longer on the command line (piped via--stdin_initial_metadata, v1.9577+; see rsvedant/opencode-windsurf-auth#8). windsurfAuthStatuskey'suserStatusProtoBinaryBase64blob — parses, but is static config: byte-identical before/after usage. Its float fields (0.5/0.25/0.1) are model cost multipliers, not live quota. (This same JSON also holds a plaintextapiKey— never read/log it.)- Cascade conversation files
~\.codeium\windsurf\cascade\*.pb— where per-message token counts would live, but they're encrypted (entropy ~8.0 bits/byte, no gzip/zlib/brotli magic). Not readable locally.
- Live LS HTTP poll (Antigravity-style) — LS listens on plain HTTP
(
- Caveats: quota-percent, not exact tokens (integer 1% steps — coarse
for light usage; same "% not tokens" limitation as Antigravity).
remainingMessagesis a separate, coarser counter (a 2500-message budget that decrements per message) — prefer the percents. Non-rebranded Windsurf installs use%APPDATA%\Windsurf\...instead of...\Devin\...; engine should try both dirs. Pricing moved to daily/weekly quotas (2026-03-19). - No token count is possible locally — don't try to add one (verified
2026-07-15). Exact tokens exist only in the server stream
(
inference.codeium.com, would need HTTPS MITM of the Electron app — out of scope) or the encrypted cascade files. Estimation viajs-tiktoken(the ChatGPT/Gemini DOM approach) is also impossible: it needs the conversation text, and every local copy is encrypted (~\.codeium\windsurf \cascade\*.pb, entropy ~7.9) or absent (chat.ChatSessionStore.indexis a 26-byte index with no content; the Devin ACP logs at%APPDATA%\Devin\cli \logs\are operational only, no per-message usage). Quota-% is the ceiling of what this integration can report. - Also checked and ruled out (2026-07-16), so nobody re-digs:
- LS trajectory endpoint (
GetCascadeTrajectoryGeneratorMetadata, which gives Antigravity its exact token counts) is on the same CSRF-gated LS — sending the localapiKeyasx-codeium-csrf-tokenreturns401 invalid CSRF token(header IS read, key is just wrong), so the endpoint would work if we had the real CSRF token. That token is injected via stdin (--stdin_initial_metadata) and lives only in process memory — no on-disk copy. Extracting it = reading process memory, which is fragile and breaks every update. Not a shippable foundation. ~\.codeium\windsurf\user_settings.pb— readable but config only (model pricing UI strings like "1M tokens", "Prompt Cache Retention").%LOCALAPPDATA%\devin\(telemetry_state.json,team_settings.bin,model_configs_v4.bin) — telemetry/config, no usage counts.- Net: exact and estimated per-message tokens are both architecturally
unavailable locally. Conversation content is encrypted and the only
token-bearing endpoint is CSRF-gated by design (the
stdin_initial_metadatamove was specifically to stop local token extraction). The one remaining untested avenue is polling the vendor cloud (server.self-serve.windsurf.com) with theapiKey— but that sends the credential off-machine and almost certainly still returns quota-shaped data (Cognition exposes no per-token number even in its own UI), so it likely buys nothing over the local quota-% signal.
- LS trajectory endpoint (
- Cognition acquired Windsurf in 2025 and shipped an OTA rebrand to Devin
Desktop (2026-06-02). On disk it installs as
Cursor — ENGINE BUILT:
lib/cursor-engine.js(per-model; exact or estimated tokens). Verified-live 2026-07-16 (v3.9.16); the best app target so far — per-model + readable content locally. VS Code-based, data under%APPDATA%\Cursor+~\.cursor. Everything lives instate.vscdb'scursorDiskKVtable:- Conversations:
composerData:<composerId>(JSON). Model is atmodelConfig.modelName— so per-model attribution works (isolate Claude vs GPT vs Cursor's owncomposer-2.5). A composer'sfullConversationHeadersOnlylists its message bubble ids in order. - Messages:
bubbleId:<composerId>:<bubbleId>(JSON), one per message.type:1= user,type:2= AI. Each has:tokenCount:{inputTokens,outputTokens}— the exact field. BUT it was{0,0}for acomposer-2.5message (Cursor's included model doesn't meter tokens locally on the free plan). Whether it populates for metered models (Claude via Cursor Pro, or a BYO API key) is still unverified — needs a Pro/API account to confirm.text— the plaintext message content (verified: user prompt and the full 7 KB assistant reply both present, unencrypted), pluscodeBlocks,allThinkingBlocks. So token estimation via bundledjs-tiktokenalways works (the ChatGPT/Gemini approach), regardless of model/plan — this is the reliable path whentokenCountis 0.
- Full conversation content is ALSO in plaintext
agentKv:blob:<sha>entries ({"role":"user"|"assistant","content":...}), but the per-bubbletextfield is the simpler source. - Cursor also stores
cursorAuth/accessToken+stripeMembershipTypelocally (server-side dashboard is the authoritative exact-usage source if ever needed — cloud call, credential leaves machine). - How the engine works: polls
cursorDiskKVevery 8s for newbubbleId:*rows (dedupes by bubbleId incursor-state.json, baselines existing history on first run); for each newtype:2AI bubble it takesmodelfrom the parentcomposerData.modelConfig.modelName, usestokenCountif nonzero (accuracy:"exact") else estimates output viajs-tiktokenon the AI bubble text/thinking/code and input via the precedingtype:1bubble text (accuracy:"estimated"), and emitsTOKENWRAP_USAGEwithsource:"Cursor". Skips bubbles still in the composer'sgeneratingBubbleIdsso mid-stream text isn't undercounted.js-tiktokenwas already abridgedependency (same lib the extension bundles) — no new deps. - Caveat: model is composer-level, so if a user switches models mid-
conversation, messages are attributed to the composer's current
modelName. Per-bubble model isn't exposed.
- Conversations:
GitHub Copilot (VS Code) — ENGINE BUILT:
lib/copilot-engine.js(per-model, EXACT tokens). Verified-live 2026-07-16. Correcting an earlier wrong guess here: Copilot does expose exact per-request token counts locally — it is NOT estimation-only. How it works:- Copilot Chat sessions are stored as incremental JSONL "patch logs":
%APPDATA%\Code\User\globalStorage\emptyWindowChatSessions\<id>.jsonl(no folder open) and...\workspaceStorage\<hash>\chatSessions\<id>.jsonl(folder open). Note the.jsonlextension — the older empty.jsonfiles in those dirs are a dead format. Each line is{kind,k,v}: kind 0 = version, 1 = keyed metadata, 2 = a path snapshot/patch (k=["requests"],v=[...requests]). - Each request object holds exact server-reported usage:
requestId(dedupe key),promptTokens,completionTokens, and bothmodelId(the user's pick, e.g.copilot/auto) andresult.metadata.resolvedModel(what Auto actually ran, e.g.gpt-5.4-mini-…, or a Claude id) — so we attribute to the resolved model. Also carriescopilotCredits(the premium-request cost) if we ever want it. - Engine mirrors
lib/cursor-engine.js: polls the JSONL files every 8s, scans every line for request objects, keeps the last (final) numbers perrequestId, dedupes viacopilot-state.json(baselines existing history on first run), emitsTOKENWRAP_USAGEwithaccuracy:"exact". No tiktoken needed.chat.modelsControlconfirms Copilot offers Claude (Haiku 4.5 / Sonnet 4.6 free, Sonnet 5 paid), so Claude-in-Copilot is fully attributable with exact counts. - Caveat: token counts appear once a request completes; mid-stream requests (0/0) are skipped and picked up on a later poll.
- Copilot Chat sessions are stored as incremental JSONL "patch logs":
Claude Desktop app (
%APPDATA%\Claude) — investigated 2026-07-16. Two distinct surfaces:- Claude Code "cowork" inside the desktop app → ALREADY CAPTURED, no work
needed. Its
claude-code-sessions\*.jsonfiles are only session metadata (model, cwd, PR state — no tokens); the real transcripts are written to the standard~/.claude/projects/, whichlib/claude-code-engine.jsalready tails (exact tokens). Same for theanthropic.claude-codeVS Code extension — all Claude Code hosts funnel to~/.claude/projects. - Desktop chat (claude.ai conversations) → not worth building. It's an
Electron webview of claude.ai; conversation state sits in
IndexedDB\https_claude.ai_0.indexeddb.leveldb(V8 structured-clone serialized — not readable viastrings; needs a real LevelDB reader), the store carries aLOCKwhile the app runs (can't safely open it externally), and claude.ai is estimation-only for tokens anyway ([[claude-ai-web-no-exact-tokens]]) with conversation content largely server-driven. Low yield, high friction — skip unless a claude.ai token signal materializes.
- Claude Code "cowork" inside the desktop app → ALREADY CAPTURED, no work
needed. Its