hSATAC/edge-usage-dashboard

Always-on rate-limit dashboard for a Corsair XENEON EDGE (2560x720): Claude accounts via cswap and Codex via its app-server, with reset countdowns, pace ticks and reset-credit stubs

★ 2Forks 0HTMLGitHub ↗Compare
claude-codecodexdashboardhammerspoonmacosrate-limitxeneon-edge

README

EDGE Usage Dashboard

Two Claude accounts and one Codex account on the 2560x720 strip

An always-on usage strip for a Corsair XENEON EDGE (2560x720) driven by a Mac. It shows the rate-limit windows of every Claude account managed by cswap and of the local Codex login: 5-hour session, weekly, and model-scoped limits, each with reset time, countdown, and a pace tick. The Codex card also charts tokens per day for the last two weeks and shows each "Full reset" credit as a ticket stub with its expiry. A Claude card shows the same stubs for promotional usage-limit resets (what Claude Code offers as /limit-reset) when cswap reports them: that needs a cswap with the usage.resetGrants setting (see the fork note under Requirements).

The dashboard never touches a token. cswap owns the Claude credentials and paces the upstream requests; the codex CLI owns the ChatGPT credential and answers over its local JSON-RPC app-server. The collector only reads their output.

cswap list --json ─┐
                    ├─▶ collector (Node, 127.0.0.1:8790) ─▶ /api/usage.json ─▶ web/ (Chrome kiosk on the EDGE)
codex app-server ──┘

Hammerspoon watches the display set: it opens the kiosk when the EDGE appears and closes it when the EDGE is unplugged.

Requirements

  • macOS with System Settings → Desktop & Dock → Mission Control → "Displays have separate Spaces" turned ON (log out and in after changing it). Without it, Chrome's kiosk fullscreen blanks the other displays.
  • Node 22 or newer.
  • cswap with your Claude accounts added (cswap list --json must work). Claude's promotional usage-limit resets need a cswap that reports resetGrants in its JSON. Until claude-swap#391 lands, that is the feat/reset-grants branch of the hSATAC fork, with cswap config set usage.resetGrants true. Without it the Claude cards simply show no reset stubs. Run the same cswap everywhere (menu bar, watch, this collector): the usage cache is shared, and a poll by a cswap without the setting drops the grants until the next poll.
  • Codex CLI logged in (codex app-server must work).
  • Google Chrome.
  • Hammerspoon to open and close the kiosk with the display (optional; scripts/start-kiosk.sh also works by hand).

Setup

cp config.example.json config.json   # edit labels and plan text
node collector/server.js             # http://127.0.0.1:8790/  (stays in the foreground)
scripts/start-kiosk.sh               # in another terminal: opens Chrome kiosk on the 2560x720 display

If the kiosk shows a "site can't be reached" page, the collector is not running: the page is served by the collector itself.

config.json keys:

Key Meaning
host, port Where the collector listens. Keep 127.0.0.1 unless you want LAN access.
refreshSeconds Collector poll interval. cswap still paces upstream calls to at most one per 180 s per account.
claude.command Path to cswap.
claude.accounts.<alias or number> Optional label and plan text shown per account. Accounts not listed use their cswap alias.
codex.command Path to codex.
codex.label Account label shown in the Codex card.
display.width, display.height Size of the target display; the kiosk script finds it by size.
display.chrome Path to the Chrome binary.

Run at login

Collector: a LaunchAgent keeps it running.

scripts/install-launchd.sh             # installs com.edge-usage-dashboard.collector
scripts/install-launchd.sh --uninstall

Logs go to ~/Library/Logs/edge-usage-dashboard/collector.log.

Kiosk: one line in ~/.hammerspoon/init.lua (adjust the path to where you cloned the repo).

edgeKiosk = dofile(os.getenv("HOME") .. "/projects/edge-usage-dashboard/hammerspoon/edge-kiosk.lua").start()

hammerspoon/edge-kiosk.lua runs scripts/start-kiosk.sh when a display named XENEON EDGE (or of the configured size) is present and no kiosk Chrome is running, and kills the kiosk Chrome when the display disappears, so a fullscreen window never lands on your main display. Events are debounced by 2 s. Chrome is started detached from Hammerspoon, so reloading the Hammerspoon config does not close the kiosk. Override any field through start({ ... }), for example start({ screenName = "My Panel" }). From a shell, hs -c "edgeKiosk.reconcile()" re-checks, hs -c "edgeKiosk.restartKiosk()" reloads the kiosk (for example after editing web/), and hs -c "edgeKiosk.stopKiosk()" closes it.

Data shape

GET /api/usage.json

{
  "generatedAt": "2026-09-14T10:09:13.685Z",
  "refreshSeconds": 60,
  "claude": {
    "accounts": [
      {
        "id": "claude:1", "label": "company", "plan": "Team · Max 5x", "active": true, "status": "ok",
        "session": { "usedPercent": 75, "resetsAt": "…", "windowMinutes": 300, "pace": null },
        "weekly":  { "usedPercent": 20, "resetsAt": "…", "windowMinutes": 10080,
                     "pace": { "expectedPercent": 93.5, "aheadOfPace": false, "projectedExhaustionAt": "…", "willLastToReset": true } },
        "scoped":  [ { "name": "Fable", "usedPercent": 34, "resetsAt": "…", "windowMinutes": 10080, "pace": null } ],
        "resetCredits": { "available": 1, "credits": [ { "expiresAt": "…" } ] },
        "fetchedAt": "…"
      }
    ],
    "error": null, "updatedAt": "…"
  },
  "codex": {
    "label": "personal",
    "data": {
      "plan": "prolite",
      "primary":   { "usedPercent": 13, "resetsAt": "…", "windowMinutes": 10080, "pace": null },
      "secondary": null,
      "limits": [ { "id": "codex_<model>", "name": "<model display name>", "primary": { "…": "…" }, "secondary": { "…": "…" } } ],
      "resetCredits": { "available": 3, "credits": [ { "expiresAt": "…" } ] },
      "fetchedAt": "…"
    },
    "usage": { "daily": [ { "date": "2026-09-17", "tokens": 6801234 } ], "streakDays": 4, "fetchedAt": "…" },
    "error": null, "updatedAt": "…"
  }
}

claude.accounts[].resetCredits has the same shape as the Codex one, with one credit per remaining promotional reset; it is null when the account holds none or cswap does not report them. codex.data.limits lists the model-scoped limits the backend returns besides the main one (empty when there are none). codex.usage comes from account/usage/read, polled every 10 minutes: one bucket per day with usage, covering the last 30 days, and the backend rolls up whole days, so the newest bucket is normally yesterday.

Errors from either source are reported next to the last good data, never in place of it. The page shows an amber "updated … · cswap error" marker when a source fails or the collector has not produced data for three refresh intervals, and a "data 23m old" note on a Claude card when cswap has been serving the same reading for more than 15 minutes (it backs off after a 429).

Layout

The page is authored at 2560x720 (1x; the EDGE has no usable HiDPI mode on macOS) and scales to fit any other viewport, so it also works in a normal browser window. mockups/ holds the design artboards it was built from; they are Claude Design canvas files, and the ./support.js they reference is that editor's runtime, so a browser logs a missing-script error but still renders them.

Notes

  • account/rateLimits/updated notifications from codex app-server are applied immediately when they arrive; the 60 s poll is the baseline.
  • Touch on the EDGE under macOS needs a third-party single-touch driver (for example edgewise); this project has no touch controls yet.

License

MIT. See LICENSE.

Contributors

hSATAC

Issues