Skip to content

Instantly share code, notes, and snippets.

@ryanwjackson
Created May 14, 2026 19:33
Show Gist options
  • Select an option

  • Save ryanwjackson/d19e40f347dd80094e8030b4ad70c2e0 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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" "$@"
#!/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