diff --git a/contrib/swiftbar/README.md b/contrib/swiftbar/README.md
new file mode 100644
index 00000000..f58b87e2
--- /dev/null
+++ b/contrib/swiftbar/README.md
@@ -0,0 +1,62 @@
+# SwiftBar Quota Plugin (macOS)
+
+A menu bar widget for [SwiftBar](https://github.com/swiftbar/SwiftBar) that shows
+per-account Codex quota from the local codex-multi-auth cache.
+
+```text
+⚡88·[90] ← menu bar title: 5h-window remaining % per account,
+ brackets mark the account currently serving requests
+```
+
+Opening the menu renders one card per managed account:
+
+```text
+╭──────────────────────────────────╮
+│ ● account-a ACTIVE │
+│ 5h █████████░ 88% → 4h 42m │
+│ 7d ████████░░ 83% → 6d 18h │
+╰──────────────────────────────────╯
+╭──────────────────────────────────╮
+│ ○ account-b IDLE │
+│ 5h █████████░ 90% → 4h 33m │
+│ 7d █████████░ 86% → 8h 16m │
+╰──────────────────────────────────╯
+```
+
+- Green card / `●` / `ACTIVE`: the account the runtime rotation router last
+ served a request with (falls back to the stored active index).
+- `5h` / `7d`: the two Codex quota windows — bar, remaining percent, and a
+ countdown to the window reset (`4h 42m` style).
+- Rows turn orange below 30% remaining and red below 10%.
+
+## Install
+
+```bash
+brew install --cask swiftbar # if not installed
+mkdir -p ~/.swiftbar-plugins
+cp contrib/swiftbar/codex-quota.5m.sh ~/.swiftbar-plugins/
+chmod +x ~/.swiftbar-plugins/codex-quota.5m.sh
+open -a SwiftBar # pick ~/.swiftbar-plugins as the plugin folder
+```
+
+## Data source and refresh model
+
+The plugin reads the local quota cache (`quota-cache.json`), account store, and
+runtime observability files under `~/.codex/multi-auth/` (or
+`CODEX_MULTI_AUTH_DIR`). Reading the cache costs **zero quota**: the cache is
+updated passively from the rate-limit headers of real Codex traffic flowing
+through the rotation proxy, and by explicit live checks.
+
+- The `5m` in the filename is SwiftBar's re-read interval (cache only).
+- The menu re-reads the cache every time it is opened (`refreshOnOpen`).
+- **Live refresh** in the menu runs `codex-multi-auth check`, which sends one
+ minimal probe per account and consumes a small amount of quota.
+
+## Notes
+
+- macOS only (SwiftBar). Card borders require a Menlo-native glyph set; if you
+ edit the card interior, avoid CJK or emoji — they render in a fallback font
+ with non-integer widths and break the right border alignment.
+- The cache file formats are internal to codex-multi-auth and may change
+ between versions; the plugin fails soft (shows `⚡?`) when they do.
+- Account names shown are the local-part of each account email.
diff --git a/contrib/swiftbar/codex-quota.5m.sh b/contrib/swiftbar/codex-quota.5m.sh
new file mode 100755
index 00000000..fa1fcb6b
--- /dev/null
+++ b/contrib/swiftbar/codex-quota.5m.sh
@@ -0,0 +1,149 @@
+#!/bin/bash
+# Codex Multi-Auth Quota
+# v1.0
+# codex-multi-auth contributors
+# macOS menu bar cards for codex-multi-auth account quota (5h/7d windows, active-account marker, reset countdowns)
+# codex-multi-auth,python3
+# true
+# true
+#
+# Reads the local codex-multi-auth quota cache (zero quota cost). The cache is
+# written by quota-bearing commands (`check`, `forecast --live`, the interactive
+# dashboard); the "Live refresh" menu item runs one such check, sending a
+# minimal probe per account.
+
+if [ "$(uname -s)" != "Darwin" ]; then
+ echo "⚡"
+ echo "---"
+ echo "Codex quota plugin requires macOS (SwiftBar) | color=gray"
+ exit 0
+fi
+
+if [ "$1" = "livecheck" ]; then
+ export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/.npm-global/bin:$PATH"
+ codex-multi-auth check
+ exit $?
+fi
+
+SELF="$0"
+
+/usr/bin/python3 - "$SELF" <<'PYEOF'
+import json, os, sys, time
+
+SELF = sys.argv[1]
+HOME = os.path.expanduser("~")
+DATA_DIR = os.environ.get("CODEX_MULTI_AUTH_DIR") or os.path.join(HOME, ".codex", "multi-auth")
+CACHE = os.path.join(DATA_DIR, "quota-cache.json")
+STORE = os.path.join(DATA_DIR, "openai-codex-accounts.json")
+OBSERV = os.path.join(DATA_DIR, "runtime-observability.json")
+
+GREEN = "#34C759"
+GRAY = "#9A9A9E"
+RED = "#FF3B30"
+ORANGE = "#FF9F0A"
+# Card interior must stay within Menlo-native glyphs (ASCII, box drawing,
+# block elements, arrows, geometric circles). CJK or emoji fall back to other
+# fonts with non-integer widths and break the right border alignment.
+W = 32
+
+def load(path):
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except Exception:
+ return None
+
+def pad(s, width):
+ return s + " " * max(0, width - len(s))
+
+def fmt_reset(ms):
+ left = int(ms / 1000 - time.time())
+ if left <= 0:
+ return "now"
+ d, r = divmod(left, 86400)
+ h, r = divmod(r, 3600)
+ m = r // 60
+ if d > 0:
+ return f"{d}d {h}h"
+ if h > 0:
+ return f"{h}h {m}m"
+ return f"{m}m"
+
+def clamp_pct(value):
+ try:
+ return max(0, min(100, int(value)))
+ except (TypeError, ValueError):
+ return 0
+
+def bar(remaining, slots=10):
+ filled = round(remaining / 100 * slots)
+ return "█" * filled + "░" * (slots - filled)
+
+def quota_color(rem):
+ if rem < 10: return RED
+ if rem < 30: return ORANGE
+ return None
+
+cache = load(CACHE)
+store = load(STORE)
+if not cache or not store:
+ print("⚡?")
+ print("---")
+ print("Cannot read quota cache or account store | color=red")
+ print(f"Expected under: {DATA_DIR} | size=11 color=gray")
+ sys.exit(0)
+
+emails, order = {}, []
+for acc in store.get("accounts", []):
+ aid = acc.get("accountId", "")
+ emails[aid] = acc.get("email", aid[-6:] if aid else "?")
+ order.append(aid)
+
+by_id = cache.get("byAccountId", {})
+
+observ = load(OBSERV) or {}
+active_id = observ.get("lastAccountId")
+if active_id not in order:
+ idx = store.get("activeIndex")
+ active_id = order[idx] if isinstance(idx, int) and 0 <= idx < len(order) else None
+
+titles, blocks = [], []
+newest = 0
+for aid in order:
+ email = emails.get(aid, "?")
+ short = email.split("@")[0]
+ is_active = (aid == active_id)
+ frame = GREEN if is_active else GRAY
+ dot = "●" if is_active else "○"
+ tag = "ACTIVE" if is_active else "IDLE"
+ rows = [(pad(f"{dot} {short}", W - len(tag)) + tag, None)]
+ q = by_id.get(aid)
+ if not q:
+ titles.append("?")
+ rows.append((pad("no quota data", W), None))
+ else:
+ newest = max(newest, q.get("updatedAt", 0))
+ for key, label in (("primary", "5h"), ("secondary", "7d")):
+ win = q.get(key, {})
+ rem = clamp_pct(100 - clamp_pct(win.get("usedPercent", 0)))
+ reset = fmt_reset(win["resetAtMs"]) if win.get("resetAtMs") else "-"
+ rows.append((pad(f"{label} {bar(rem)} {rem:>3}% → {reset}", W), quota_color(rem)))
+ if key == "primary":
+ titles.append(f"[{rem}]" if is_active else str(rem))
+ blocks.append((frame, rows))
+
+print("⚡" + "·".join(titles))
+print("---")
+style = "font=Menlo size=12 trim=false emojize=false"
+for frame, rows in blocks:
+ print(f"╭{'─' * (W + 2)}╮ | color={frame} {style}")
+ for text, override in rows:
+ print(f"│ {text} │ | color={override or frame} {style}")
+ print(f"╰{'─' * (W + 2)}╯ | color={frame} {style}")
+if newest:
+ age_min = int((time.time() - newest / 1000) / 60)
+ age = "just now" if age_min < 1 else (f"{age_min}m ago" if age_min < 60 else f"{age_min//60}h ago")
+ print(f"Cache updated {age} | size=11 color=gray")
+print(f"Live refresh (one probe per account) | bash={SELF} param1=livecheck terminal=false refresh=true")
+print("Reload from cache | refresh=true")
+PYEOF