A tiny, fast LLM quota display for the macOS menu bar. Providers publish to it; it never calls them. https://vibewait.ing/eaSpot/
  • Swift 96.6%
  • Shell 3.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
xicv 7c0ba3fffb docs: install eaSpot with Homebrew from the x/tap forge tap
The cask moved from the dead xicv/tap on GitHub to x/homebrew-tap on
x.vibewait.ing. release.sh updates that tap and no longer suggests a
GitHub release.
2026-09-28 18:29:02 +09:30
Resources/AppIcon.icon feat: add app icon, signed release pipeline and MIT license 2026-09-25 15:40:57 +09:30
scripts docs: install eaSpot with Homebrew from the x/tap forge tap 2026-09-28 18:29:02 +09:30
Sources fix: refuse stylesheet imports and document type declarations in published icons 2026-09-27 10:49:29 +09:30
Tests/EaSpotKitTests fix: refuse stylesheet imports and document type declarations in published icons 2026-09-27 10:49:29 +09:30
.gitignore feat: add app icon, signed release pipeline and MIT license 2026-09-25 15:40:57 +09:30
.swiftlint.yml feat(app): add eaSpot menu bar app and drop the statusline writer 2026-09-25 14:59:39 +09:30
LICENSE feat: add app icon, signed release pipeline and MIT license 2026-09-25 15:40:57 +09:30
Package.swift feat(app): add eaSpot menu bar app and drop the statusline writer 2026-09-25 14:59:39 +09:30
README.md docs: install eaSpot with Homebrew from the x/tap forge tap 2026-09-28 18:29:02 +09:30

eaSpot

A tiny, fast LLM quota display for the macOS menu bar. Website: vibewait.ing/eaSpot · Source: x.vibewait.ing/x/eaSpot

This repository holds:

  • eaSpot.app (Sources/EaSpotApp): the menu bar item and menu. AppKit only for everything that stays resident; the SwiftUI Settings window is created on first use.
  • easpot (Sources/easpot): the writers that turn Claude Code and Codex events into snapshot files.
  • EaSpotKit: the shared data contract, merge rules, and the pace/tone judgements (tested).

The app never talks to a provider. Providers conform to eaSpot's data contract instead: an adapter writes a snapshot, and the app renders whatever snapshots exist.

eaSpot is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic or OpenAI. "Claude" and "Codex" are used only to label the limits those tools report.

Requires macOS 14 or later, on Apple silicon or Intel. The layered Liquid Glass icon appears on macOS 26 and later.

Install

  1. Install with Homebrew, which puts eaSpot.app in /Applications and easpot on your PATH:

    brew tap x/tap https://x.vibewait.ing/x/homebrew-tap.git
    brew install --cask x/tap/easpot
    

    Or download eaSpot-<version>.zip from the releases page, unzip it, and move eaSpot.app to /Applications (or ~/Applications). Releases are signed with a Developer ID and notarized.

  2. Open eaSpot. It lives in the menu bar and shows "No usage yet" until a writer reports.

  3. Connect the writers. Print the hook snippets with the CLI that ships inside the app:

    APP=/Applications/eaSpot.app   # or ~/Applications/eaSpot.app, wherever you put it
    "$APP/Contents/Helpers/easpot" print-config
    

    Add the Stop group to hooks in ~/.claude/settings.json, and the Stop and PostToolUse groups to ~/.codex/hooks.json (at the end of each list). In Codex, approve the new hooks with /hooks. Optionally put the CLI on your PATH (any directory on it works):

    mkdir -p ~/.local/bin && ln -sf "$APP/Contents/Helpers/easpot" ~/.local/bin/easpot
    
  4. Use Claude Code or Codex. Numbers appear within seconds (Codex) or a few minutes (Claude).

How data arrives

Where you work Writer Latency Cost
Claude Code (terminal, desktop app, IDE) easpot hook claude-stop starts a detached claude -p "/usage" probe when data is older than 3 min or a window has reset ≤ 3 min + ~3 s hook ~5–8 ms; probe ~2–3 s, 0 model tokens
Codex CLI and desktop app easpot hook codex (Stop / PostToolUse) reads the newest token_count in rollout logs end of each tool call or turn ~12 ms
Anything else easpot publish with a provider-neutral snapshot on stdin adapter-defined —
Menu open with old or reset data the app runs its bundled easpot refresh all --if-older 120 ~3 s as above

Hooks run outside the model, so none of this uses model tokens.

Data contract (schema 1)

Root: ~/.config/easpot (override with EASPOT_HOME). Readers watch sources/ only.

sources/<source>.json   one snapshot per provider, replaced atomically (dot-prefixed temp + rename)
state/                  locks, probe stamps, hook traces (hook-*.last), errors.log — never watched
{
  "schema": 1,
  "source": "claude",
  "plan": null,
  "updated_at": 1790301234.5,
  "windows": [
    {
      "id": "seven_day",
      "label": "Weekly",
      "used_percent": 19,
      "resets_at": 1790791200,
      "resets_at_exact": false,
      "window_minutes": 10080,
      "observed_at": 1790301230.1,
      "via": "usage-probe"
    }
  ]
}

All times are Unix epoch seconds. window_minutes may be null when the length is unknown (for example a spend limit). resets_at is absent for a limit that never resets, such as a prepaid balance. A reader should treat a window whose resets_at has passed as reset, and use observed_at for "updated X ago".

A window may also carry absolute amounts, "used": 12, "limit": 200, "unit": "messages", which the menu shows as "188 of 200 messages left". used_percent is always present and drives the bar and warnings.

Window IDs: Claude five_hour, seven_day, seven_day:<model> (e.g. seven_day:fable), spend_limit; Codex <limit_id>:<window_minutes> (e.g. codex:10080, codex_bengalfox:10080 for Codex-Spark).

Merge rules

Many sessions report at once and some report stale numbers, so every writer merges under a lock:

  • A resets_at more than 30 min away from the stored one is a different window, and whichever reading was observed later wins (on a tie, the later reset). So a new window replaces the old one, a stale reading of a finished window is ignored, and a newer reading that moves the reset earlier sticks.
  • Within a window, a reading at least as new as the stored one replaces it; an older reading can only raise usage (usage never shrinks within a window). Unknown via values read as published.
  • Windows missing from a reading are kept. Windows that reset more than 8 days ago are dropped.
  • The file is rewritten only when a value changes or an observation gets ≥ 60 s fresher.

Publishing from any provider

echo '{"source":"glm","plan":"pro","windows":[
  {"id":"five_hour","used_percent":12.5,"resets_at":1790330400,"window_minutes":300}]}' | easpot publish

source must match [a-z0-9][a-z0-9_-]{0,31}. name (how the menu names the source, e.g. "ChatGPT", up to 32 characters; kept until a later publish replaces it), label, window_minutes, observed_at (default now) and plan are optional; resets_at may be epoch seconds or ISO-8601.

A tool that counts instead of measuring a percentage sends used and limit (and optionally unit, up to 24 characters), and used_percent is worked out from them:

echo '{"source":"chatgpt","name":"ChatGPT","windows":[{"id":"pro_weekly","label":"Pro weekly",
  "used":12,"limit":200,"unit":"messages","resets_at":"2026-10-02T00:00:00Z","window_minutes":10080}]}' | easpot publish

A source can also choose its icon, drawn next to its number in the menu bar, in the menu and in Settings:

"icon": { "symbol": "bubble.left.fill" }
"icon": { "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\"><path d=\"M2 3h12v8H6l-3 3v-3H2z\"/></svg>" }

symbol is an SF Symbol name; svg is a small SVG (up to 16 KB, no scripts or outside references), for example one a model generated. Both are drawn as a one-colour shape so they follow the menu bar's appearance and warning colours; an SVG's own colours are ignored. Like name, the icon is kept until a later publish replaces it. Without one, eaSpot chooses: its own drawings echoing each app's icon for Claude (a starburst) and Codex (a cloud with a prompt), and the first letter of the name in a rounded square for anything else.

Leave out resets_at for a limit that never resets; the menu then says "does not reset" and shows no pace. used must be the running total for the window, not an increment.

Commands

easpot hook claude-stop | hook codex
easpot publish < snapshot.json
easpot refresh [claude|codex|all] [--if-older <seconds>] [--detach]
easpot status [--json]
easpot print-config

The probe fills in USER/LOGNAME when a hook's environment lacks them, since Claude Code needs them to find its Keychain login.

Environment: EASPOT_HOME, EASPOT_CLAUDE_BIN, EASPOT_CLAUDE_MIN_INTERVAL (default 180), EASPOT_PROBE_TIMEOUT (default 30), CODEX_HOME.

The menu bar app

One symbol and number per service (or only the tightest limit), showing what's left of the limit that needs attention first. Click for a native menu: each limit as a bar whose fill is what's left, with a tick at even pace — fill past the tick means quota to spare, short of it means running hot — plus a plain-language line such as "Runs out Mon about 2:50 PM · resets Wed 9:15 AM".

  • Updates within ~0.1 s of a writer changing sources/ (directory watch, debounced), once a minute for countdowns, exactly at each reset, and after wake. No polling, no network.
  • Opening the menu when any shown limit is older than the Settings value (default 2 min), or has reset, runs the bundled easpot refresh all --if-older <age>. "Refreshing…" lasts until it exits, and a failure is shown with a pointer to state/errors.log.
  • Warnings use colour plus a triangle and words; light-mode warning text uses darker tones for contrast.

eaSpot --snapshot <folder> renders the menu bar item, the menu and Settings (light and dark) from the real data into PNGs, and prints how long building the menu takes.

Build from source

Requires Xcode 26 or later (for actool to compile the Icon Composer icon).

swift test
scripts/bundle.sh             # build/eaSpot.app, ad-hoc signed
scripts/bundle.sh --install   # also replace the installed app (~/Applications by default) and relaunch it

The icon source is Resources/AppIcon.icon; open it in Icon Composer to edit. bundle.sh compiles it into Assets.car (layered icon on macOS 26+) and AppIcon.icns (older macOS).

bundle.sh builds a universal app (Apple silicon and Intel), so its binaries land in the folder swift build -c release --arch arm64 --arch x86_64 --show-bin-path prints, not .build/release.

bundle.sh warns when ~/.local/bin/easpot runs something other than this build. If it is a symlink to the app's helper (as in Install above), --install updates both. If it is a separate copy, the warning prints the install command that refreshes it.

Hooks that go through the symlink fail briefly while --install replaces the app; they recover on the next event.

Releasing

Signed with a Developer ID, notarized and stapled, so Gatekeeper opens it without warnings:

xcrun notarytool store-credentials <profile> --team-id <TEAMID>   # once; prompts for credentials
EASPOT_SIGN_IDENTITY="Developer ID Application: <Name> (<TEAMID>)" \
EASPOT_NOTARY_PROFILE=<profile> scripts/release.sh                # dist/eaSpot-<version>.zip + SHA-256

Both the app and its helper are signed inside-out with the hardened runtime and a secure timestamp.

Then tag the release and attach the zip and its SHA-256 to a release at x.vibewait.ing/x/eaSpot/releases.

License

MIT. See LICENSE.