npm.io
0.3.0 • Published 1 month agoCLI

tokenez

Licence
ISC
Version
0.3.0
Deps
4
Size
4.0 MB
Vulns
0
Weekly
0

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 shape
  • GET / — 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 into quotaStats
  • TOKENWRAP_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.jsdist-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.

  1. build-exe.js copies node.exe, a console-subsystem binary, but the shipped exe is launched from Explorer with no console attached — so every console.log/console.error in tray-app.js wrote to nowhere. A failed systray.ready() therefore produced no visible error of any kind.
  2. On that failure the process used to stay alive with engines started and the hub port bound. Because tray-app.js exits 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_tokens is reported separately as cacheReadTokens, 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 was input_tokens: 2 per call). Log entries before 2026-07-16 use the old inflated semantics.
  • Watches ~/.claude/projects recursively 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
  1. Finds the running language_server_windows_x64.exe process (the account-level one, without --workspace_id)
  2. Extracts its --csrf_token from the process command line
  3. Finds which port it's actually listening on right now (ports and tokens change every time Antigravity restarts — this is handled automatically)
  4. Calls GetUserStatus over local HTTPS every 60 seconds
  5. Diffs each model's remainingFraction against the last poll
  6. 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/ps equivalents instead.
  • Gives you % of quota consumed, not raw token counts — Antigravity doesn't expose an explicit per-model token ceiling, only a shared monthlyPromptCredits / monthlyFlowCredits pool 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 generalize PROCESS_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%\Devin and ~\.codeium\windsurf; bundled LS is still extensions\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 Code ItemTable key/value store), key windsurf.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, dailyRemainingPercent went 99→98 and weeklyRemainingPercent 100→99, exactly matching Devin's own in-product Usage panel (2% daily / 1% used). Engine = read this key on an interval via built-in node:sqlite (Node 22+, DatabaseSync, readOnly:true), JSON.parse, diff the percents, broadcast a quota-drop payload like lib/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) but GetUserStatus returns 401 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).
      • windsurfAuthStatus key's userStatusProtoBinaryBase64 blob — 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 plaintext apiKey — 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.
    • Caveats: quota-percent, not exact tokens (integer 1% steps — coarse for light usage; same "% not tokens" limitation as Antigravity). remainingMessages is 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 via js-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.index is 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 local apiKey as x-codeium-csrf-token returns 401 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_metadata move was specifically to stop local token extraction). The one remaining untested avenue is polling the vendor cloud (server.self-serve.windsurf.com) with the apiKey — 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.
  • CursorENGINE 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 in state.vscdb's cursorDiskKV table:

    • Conversations: composerData:<composerId> (JSON). Model is at modelConfig.modelName — so per-model attribution works (isolate Claude vs GPT vs Cursor's own composer-2.5). A composer's fullConversationHeadersOnly lists 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 a composer-2.5 message (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), plus codeBlocks, allThinkingBlocks. So token estimation via bundled js-tiktoken always works (the ChatGPT/Gemini approach), regardless of model/plan — this is the reliable path when tokenCount is 0.
    • Full conversation content is ALSO in plaintext agentKv:blob:<sha> entries ({"role":"user"|"assistant","content":...}), but the per-bubble text field is the simpler source.
    • Cursor also stores cursorAuth/accessToken+stripeMembershipType locally (server-side dashboard is the authoritative exact-usage source if ever needed — cloud call, credential leaves machine).
    • How the engine works: polls cursorDiskKV every 8s for new bubbleId:* rows (dedupes by bubbleId in cursor-state.json, baselines existing history on first run); for each new type:2 AI bubble it takes model from the parent composerData.modelConfig.modelName, uses tokenCount if nonzero (accuracy:"exact") else estimates output via js-tiktoken on the AI bubble text/thinking/code and input via the preceding type:1 bubble text (accuracy:"estimated"), and emits TOKENWRAP_USAGE with source:"Cursor". Skips bubbles still in the composer's generatingBubbleIds so mid-stream text isn't undercounted. js-tiktoken was already a bridge dependency (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.
  • 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 .jsonl extension — the older empty .json files 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 both modelId (the user's pick, e.g. copilot/auto) and result.metadata.resolvedModel (what Auto actually ran, e.g. gpt-5.4-mini-…, or a Claude id) — so we attribute to the resolved model. Also carries copilotCredits (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 per requestId, dedupes via copilot-state.json (baselines existing history on first run), emits TOKENWRAP_USAGE with accuracy:"exact". No tiktoken needed. chat.modelsControl confirms 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.
  • 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\*.json files are only session metadata (model, cwd, PR state — no tokens); the real transcripts are written to the standard ~/.claude/projects/, which lib/claude-code-engine.js already tails (exact tokens). Same for the anthropic.claude-code VS 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 via strings; needs a real LevelDB reader), the store carries a LOCK while 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.

Keywords