-
-
Save ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0 to your computer and use it in GitHub Desktop.
gh shim: caches cmux PR polling to survive GitHub's 5000/hr GraphQL rate limit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================ | |
| # gh wrapper — caches cmux's PR-polling calls to survive GitHub's 5000/hr cap | |
| # ============================================================================ | |
| # | |
| # WHY THIS EXISTS | |
| # --------------- | |
| # The cmux terminal multiplexer (/Applications/cmux.app) has a native Swift | |
| # TabManager poller that continuously runs `gh pr checks` and `gh pr list` | |
| # for every PR and branch in its sidebar — selected workspace every ~5s, | |
| # tracked workspaces every ~30s. With ~20 open PRs/branches that saturates | |
| # GitHub's 5000/hr GraphQL quota within minutes. | |
| # | |
| # Once the GraphQL bucket is exhausted, every local `gh` call that touches | |
| # GraphQL fails, which breaks: | |
| # - /autofix-pr (fails on `gh pr view` before it can spawn the cloud session) | |
| # - gh pr list / gh pr view / gh pr checks / gh search / etc. | |
| # - Any script or slash command that shells out to gh | |
| # | |
| # WHY WE CAN'T JUST FIX CMUX | |
| # -------------------------- | |
| # As of 2026-04-09 cmux has no setting, env var, or preference key to disable | |
| # the polling. `sidebar.showPullRequests: false` in ~/.config/cmux/settings.json | |
| # only hides the UI rendering — the background fetch runs regardless. The | |
| # shell-integration zsh poller honors _CMUX_PR_POLL_INTERVAL, but the Swift | |
| # TabManager poller (the one actually spawning the traffic) ignores it. No | |
| # kill switch has shipped upstream. | |
| # | |
| # HOW THIS SHIM WORKS | |
| # ------------------- | |
| # 1. Fingerprints cmux's two distinctive arg patterns (no human types these): | |
| # gh pr checks <N> --repo R --json bucket,state | |
| # gh pr list --repo R --state all --head <branch> --json number,state,url,updatedAt | |
| # 2. Hashes the full arg string, caches responses in /tmp/gh-shim-cache/<hash> | |
| # for CACHE_TTL seconds (default 5 min). Cache hit → serve from disk. | |
| # 3. Cache miss → run the real gh, cache stdout on success (status 0 only, | |
| # so we never cache rate-limit errors). | |
| # 4. Every other gh invocation is passed through to the real binary via exec, | |
| # so interactive use, scripts, Claude Code, CI tooling, etc. are unaffected. | |
| # | |
| # cmux invokes gh by absolute path (/opt/homebrew/bin/gh), so installing this | |
| # shim takes effect immediately — no cmux restart needed. | |
| # | |
| # IMPACT | |
| # ------ | |
| # ~5000 GraphQL calls/hr → ~240 GraphQL calls/hr (≈20× reduction) | |
| # Well under the 5000/hr cap with plenty of headroom for /autofix-pr, manual | |
| # gh use, and other tooling. | |
| # | |
| # LAYOUT | |
| # ------ | |
| # /opt/homebrew/bin/gh — this shim (regular file; brew won't overwrite it) | |
| # /opt/homebrew/opt/gh/bin/gh — brew's stable, unversioned symlink to the | |
| # current Cellar binary. We exec this directly | |
| # so `brew upgrade gh` can never break us. | |
| # /tmp/gh-shim-cache/ — per-fingerprint response cache (wiped on reboot) | |
| # | |
| # CAVEATS | |
| # ------- | |
| # - cmux will see PR status up to 5 minutes stale (acceptable trade — the | |
| # alternative was zero gh functionality while rate-limited). | |
| # - If cmux changes its polling arg format in a future release, the fingerprint | |
| # match silently stops working and the rate-limit exhaustion returns. Detect | |
| # by checking `ls /tmp/gh-shim-cache/` during active cmux use — empty = drift. | |
| # - If brew's prefix moves (e.g. Apple Silicon → Intel migration), update | |
| # REAL_GH below. | |
| # | |
| # UPGRADE BEHAVIOR | |
| # ---------------- | |
| # `brew upgrade gh` updates the Cellar dir and re-points | |
| # /opt/homebrew/opt/gh/bin/gh at the new version. This shim resolves the real | |
| # binary through that stable path, so upgrades just work — no reinstall needed. | |
| # The shim file itself is a regular file (not a brew-managed symlink), so brew | |
| # leaves it alone. | |
| # | |
| # INSTALL | |
| # ------- | |
| # One-liner (clones the gist, runs the installer, which handles brew + path): | |
| # curl -fsSL https://gist.githubusercontent.com/ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0/raw/install.sh | bash | |
| # | |
| # Manual: | |
| # brew install gh # ensure real binary exists | |
| # curl -fsSL https://gist.githubusercontent.com/ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0/raw/gh -o /tmp/gh-shim | |
| # chmod +x /tmp/gh-shim | |
| # mv /tmp/gh-shim "$(brew --prefix)/bin/gh" # overwrites brew's symlink (intentional) | |
| # file "$(brew --prefix)/bin/gh" # verify: "Bourne-Again shell script" | |
| # gh --version # verify passthrough still works | |
| # | |
| # The shim auto-detects brew prefix at runtime, so it works on both Apple Silicon | |
| # (/opt/homebrew) and Intel (/usr/local) without edits. | |
| # | |
| # UNINSTALL | |
| # --------- | |
| # rm /opt/homebrew/bin/gh | |
| # brew unlink gh && brew link gh # restore brew's normal /opt/homebrew/bin/gh symlink | |
| # | |
| # See also: knowledge-base/raw/inbox/cmux-gh-polling-rate-limit-2026-04-09.md | |
| # for the full diagnosis writeup and upstream issue references. | |
| # ============================================================================ | |
| # Resolve to brew's stable unversioned symlink, which always tracks the current | |
| # Cellar version. Falls back to a $PATH lookup that excludes ourselves, in case | |
| # brew is installed at a non-default prefix. | |
| if [ -x "/opt/homebrew/opt/gh/bin/gh" ]; then | |
| REAL_GH="/opt/homebrew/opt/gh/bin/gh" | |
| elif command -v brew >/dev/null 2>&1 && [ -x "$(brew --prefix gh 2>/dev/null)/bin/gh" ]; then | |
| REAL_GH="$(brew --prefix gh)/bin/gh" | |
| else | |
| echo "gh shim: cannot locate real gh binary (looked at /opt/homebrew/opt/gh/bin/gh and brew --prefix gh)" >&2 | |
| exit 127 | |
| fi | |
| CACHE_DIR="/tmp/gh-shim-cache" | |
| CACHE_TTL=300 # seconds — 5 minutes | |
| # Persistent access log (survives reboots, unlike /tmp/gh-shim-cache). | |
| # Use this to confirm the shim is still absorbing cmux polls and decide | |
| # whether it can be removed: `tail -F ~/Library/Logs/gh-shim.log`. | |
| # Each line: <iso-ts>\t<HIT|MISS|MISS_ERR>\t<pattern>\t<pid>\t<ppid>\t<args> | |
| LOG_FILE="${HOME}/Library/Logs/gh-shim.log" | |
| LOG_MAX_BYTES=$((50 * 1024 * 1024)) # 50 MiB — rotate when exceeded; keeps one .1 backup | |
| rotate_log_if_needed() { | |
| # Cheap size check; stat is fast and we only call this on the slow path (miss/err). | |
| local size | |
| size=$(stat -f %z "$LOG_FILE" 2>/dev/null) || return 0 | |
| if [ "$size" -gt "$LOG_MAX_BYTES" ]; then | |
| mv -f "$LOG_FILE" "${LOG_FILE}.1" 2>/dev/null | |
| fi | |
| } | |
| log_poll() { | |
| # $1=event, $2=pattern; uses $args from outer scope | |
| mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null | |
| printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ | |
| "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" "$$" "$PPID" "$args" \ | |
| >> "$LOG_FILE" 2>/dev/null | |
| # Only check rotation on non-HIT events to keep the hot path cheap. | |
| [ "$1" = "HIT" ] || rotate_log_if_needed | |
| } | |
| args="$*" | |
| # cmux poll fingerprints — very specific arg patterns no human would type | |
| is_poll=false | |
| poll_pattern="" | |
| case "$args" in | |
| *"pr checks"*"--json bucket,state"*) is_poll=true; poll_pattern="pr-checks" ;; | |
| *"pr list"*"--json number,state,url,updatedAt"*) is_poll=true; poll_pattern="pr-list" ;; | |
| esac | |
| if [ "$is_poll" = true ]; then | |
| mkdir -p "$CACHE_DIR" 2>/dev/null | |
| hash=$(printf '%s' "$args" | shasum | awk '{print $1}') | |
| cache="$CACHE_DIR/$hash" | |
| if [ -f "$cache" ]; then | |
| age=$(( $(date +%s) - $(stat -f %m "$cache") )) | |
| if [ "$age" -lt "$CACHE_TTL" ]; then | |
| log_poll "HIT" "$poll_pattern" | |
| cat "$cache" | |
| exit 0 | |
| fi | |
| fi | |
| # Cache miss or stale — run real gh, cache on success | |
| output=$("$REAL_GH" "$@" 2>&2) | |
| status=$? | |
| if [ "$status" -eq 0 ]; then | |
| printf '%s' "$output" > "$cache" | |
| log_poll "MISS" "$poll_pattern" | |
| else | |
| log_poll "MISS_ERR(status=$status)" "$poll_pattern" | |
| fi | |
| printf '%s' "$output" | |
| exit $status | |
| fi | |
| # Everything else: transparent passthrough | |
| exec "$REAL_GH" "$@" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================ | |
| # gh-shim installer | |
| # ============================================================================ | |
| # Installs the gh wrapper that caches cmux's PR-polling calls so cmux can't | |
| # blow through GitHub's 5000/hr GraphQL rate limit. | |
| # | |
| # What it does: | |
| # 1. Verifies Homebrew is installed (errors out if not). | |
| # 2. Ensures `gh` is installed via brew (installs it if missing). | |
| # 3. Downloads the shim from the gist and atomically replaces | |
| # $(brew --prefix)/bin/gh with it. | |
| # 4. Verifies the install (file type + `gh --version` passthrough). | |
| # | |
| # Idempotent: safe to re-run to upgrade the shim. | |
| # | |
| # Usage: | |
| # curl -fsSL https://gist.githubusercontent.com/ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0/raw/install.sh | bash | |
| # | |
| # Uninstall: | |
| # rm "$(brew --prefix)/bin/gh" && brew unlink gh && brew link gh | |
| # ============================================================================ | |
| set -euo pipefail | |
| GIST_RAW="https://gist.githubusercontent.com/ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0/raw/gh" | |
| say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } | |
| warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } | |
| die() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } | |
| # 1. brew must exist | |
| command -v brew >/dev/null 2>&1 \ | |
| || die "Homebrew not found. Install from https://brew.sh first, then re-run this." | |
| BREW_PREFIX="$(brew --prefix)" | |
| TARGET="${BREW_PREFIX}/bin/gh" | |
| # 2. ensure gh is installed via brew (real binary must exist at $BREW_PREFIX/opt/gh/bin/gh) | |
| if [ ! -x "${BREW_PREFIX}/opt/gh/bin/gh" ]; then | |
| say "Installing gh via brew..." | |
| brew install gh | |
| else | |
| say "gh already installed at ${BREW_PREFIX}/opt/gh/bin/gh" | |
| fi | |
| # 3. fetch shim to a temp file, then atomically move into place | |
| TMP="$(mktemp -t gh-shim.XXXXXX)" | |
| trap 'rm -f "$TMP"' EXIT | |
| say "Downloading shim from gist..." | |
| curl -fsSL "$GIST_RAW" -o "$TMP" \ | |
| || die "Failed to download shim from $GIST_RAW" | |
| # Sanity check: must start with #!/bin/bash and contain the rotate function. | |
| head -1 "$TMP" | grep -q '^#!/bin/bash' \ | |
| || die "Downloaded file doesn't look like a bash script (first line: $(head -1 "$TMP"))" | |
| grep -q 'rotate_log_if_needed' "$TMP" \ | |
| || warn "Downloaded shim is missing log-rotation logic — gist may be older than expected." | |
| chmod +x "$TMP" | |
| # If target is writable by us, do a plain mv; otherwise sudo. | |
| if [ -w "$(dirname "$TARGET")" ] && { [ ! -e "$TARGET" ] || [ -w "$TARGET" ]; }; then | |
| mv -f "$TMP" "$TARGET" | |
| else | |
| say "Need sudo to install into $TARGET..." | |
| sudo mv -f "$TMP" "$TARGET" | |
| fi | |
| trap - EXIT | |
| # 4. verify | |
| file "$TARGET" | grep -q 'shell script' \ | |
| || die "Install verification failed: $TARGET is not a shell script." | |
| say "Verifying passthrough (gh --version)..." | |
| "$TARGET" --version | head -1 | |
| say "Installed gh shim at $TARGET" | |
| say "Log: ~/Library/Logs/gh-shim.log (auto-rotates at 50 MiB → .1)" | |
| say "Cache: /tmp/gh-shim-cache/ (TTL 5min, wiped on reboot)" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment