Hooks
What rolling-context runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
powershell -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/hooks/start-proxy.ps1" 2>/dev/null || bash "${CLAUDE_PLUGIN_ROOT}/hooks/start-proxy.sh" 2>/dev/null
In the plugin's words
How rolling-context describes its own hook set.
Rolling Context - auto-start proxy on session start
Where it lives
- hooks/probe.pyGitHub
Read the script
#!/usr/bin/env python3 """Ask the port who is listening on it. One implementation, both hooks. The start hook used to decide "is the proxy running?" from the PID file: does it exist, and is that PID alive. Both questions can answer yes while nothing is serving. A crashed proxy leaves its PID file behind, and the kernel is free to hand that number to an unrelated process — `kill -0` then reports "alive", the hook logs "Proxy already running", and every session that follows is pointed at ANTHROPIC_BASE_URL with no listener behind it: ConnectionRefused on every request until a human deletes the file by hand (issue #9). A PID is not the thing we care about. Whether the port answers, and whether the thing answering is us, is. That is what this asks. python probe.py [port] [timeout_seconds] prints exactly one of: ours <version> our proxy is serving (version "legacy" = older than the /health identity fields, so the hook restarts it) foreign something answers on that port, but it is not us down nothing is serving python probe.py [port] [timeout_seconds] pid prints the PID the serving proxy reports for itself, or nothing. That is the authoritative one — the PID file is a copy that can go stale, and an upgrade still has to stop whatever is really holding the port. python probe.py [port] [timeout_seconds] decide <our_version> is the whole upgrade policy in one line of output (nestor-plugins issue #1 — two plugin versions installed side by side took turns restarting the shared proxy on "version differs", cutting every session's in-flight stream each time): down nothing is serving — start ours foreign something else holds the port — do not start same our version is serving — leave it alone newer <v> a NEWER version is serving — leave it alone; the hook never downgrades older <v> idle an older version is serving with nothing in flight — replace it now older <v> busy <n> <how> an older version is serving <n> requests (how=requests: it told us via /health; how=sockets: too old to say, so we counted established client connections instead) — wait, and if it never goes idle, defer the upgrade to a later session start """ import json import os import re import subprocess import sys import urllib.error import urllib.request def health(port, timeout=2.0): """The /health body as a dict, or None if the port did not give us one.""" # ProxyHandler({}) — with http_proxy/all_proxy set (common on corporate # boxes) urlopen would route this loopback probe through the corporate # proxy, which cannot reach 127.0.0.1 and would report our own healthy # proxy as down. Ask the port directly, always. opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) try: with opener.open(f"http://127.0.0.1:{port}/health", timeout=timeout) as resp: body = json.loads(resp.read().decode("utf-8", "replace")) except urllib.error.HTTPError: # An HTTP server is up but does not serve our /health. return {} except Exception: # Refused, reset, timed out, unparseable body — nothing usable there. return None return body if isinstance(body, dict) else {} def state(body): if body is None: return "down" if body.get("service") == "rolling-context": return "ours " + str(body.get("version") or "unknown") # Proxies older than the service marker still answer /health with this # shape. They are ours, and reporting them as such is what lets the hook # replace them on upgrade instead of declaring the port hostile. if "trigger_tokens" in body and "summarizer_model" in body: return "ours legacy" return "foreign" _NUM = re.compile(r"\d+") def version_key(v): """Comparable form of a version string: numeric dotted parts only. "1.13.1" -> (1, 13, 1); "1.13.1-rc2" -> (1, 13, 1); "legacy", "unknown" and anything without digits -> () which sorts below every real version, so the hook treats it as older and replaces it. """ core = str(v or "").split("-", 1)[0].split("+", 1)[0] return tuple(int(n) for n in _NUM.findall(core)) def compare(running, ours): """-1 if the running version is older than ours, 0 if same, 1 if newer.""" a, b = version_key(running), version_key(ours) if a == b: return 0 return -1 if a < b else 1 def established_client_sockets(ports): """Established TCP connections whose LOCAL side is one of `ports`. The fallback for proxies that predate the active_requests field: a client talking to us shows up as ESTABLISHED on our listening port. `netstat -an` exists on macOS, Linux and Windows with formats that all put the local address in a column ending in :PORT or .PORT. None if netstat is missing. """ ports = {str(p) for p in ports} cmds = (["netstat", "-an"], ["ss", "-tan"]) out = None for cmd in cmds: try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=5).stdout if out: break except Exception: continue if not out: return None n = 0 for line in out.splitlines(): u = line.upper() if "ESTAB" not in u: continue cols = line.split() # local address is the first column that looks like an endpoint # (netstat: after "tcp4"/"TCP"; ss: recv/send-q columns come first) for col in cols: m = re.search(r"[:.](\d+)$", col) if m: if m.group(1) in ports: n += 1 break - hooks/start-proxy.ps1RunsGitHub
Read the script
# Ensure rolling context proxy is running (Windows) # Pure stdlib — no venv needed, just python $ErrorActionPreference = "SilentlyContinue" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProxyDir = Join-Path $ScriptDir "..\proxy" $ClaudeDir = Join-Path $env:USERPROFILE ".claude" $PidFile = Join-Path $ClaudeDir "rolling-context-proxy.pid" $VerFile = Join-Path $ClaudeDir "rolling-context-proxy.version" $HookLog = Join-Path $ClaudeDir "rolling-context-hook.log" $ProxyLog = Join-Path $ClaudeDir "rolling-context-proxy.log" $Port = if ($env:ROLLING_CONTEXT_PORT) { $env:ROLLING_CONTEXT_PORT } else { "5588" } $ProxyUrl = "http://127.0.0.1:$Port" $PluginJson = Join-Path $ScriptDir "..\.claude-plugin\plugin.json" $CurrentVersion = if (Test-Path $PluginJson) { (Get-Content $PluginJson -Raw | ConvertFrom-Json).version } else { "unknown" } function Log($msg) { $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" # UTF8 explicitly: Windows PowerShell 5.1 defaults Add-Content to the ANSI # code page, which mangles the dashes in these messages for every other reader. Add-Content -Path $HookLog -Value "[$ts] $msg" -Encoding UTF8 } function Test-PointsAtUs($url, $port) { # True only if url is OUR proxy: loopback AND our port. The old regex # "127\.0\.0\.1.*$Port" matched 127.0.0.1:15588 for port 5588, and missed # localhost:5588 entirely — which would have chained the proxy to itself. # Matching on host alone (as the sh hook did) treats every local model # endpoint as the proxy and skips chaining altogether. try { $full = if ($url -match '://') { $url } else { "http://$url" } $u = [System.Uri]$full return (@('127.0.0.1', 'localhost', '::1') -contains $u.Host) -and ($u.Port -eq [int]$port) } catch { return $false } } Log "Hook started. ProxyDir=$ProxyDir" # Wire ourselves into Claude Code via HTTPS_PROXY + NODE_EXTRA_CA_CERTS (NOT # ANTHROPIC_BASE_URL, which trips the Remote Control / GrowthBook gate). All the # settings.json bookkeeping — CA generation, HTTPS_PROXY single-owner ownership, # chaining with pii-proxy, plugin defaults, stale-base_url cleanup — lives in # wire.py so it is one tested implementation shared with the sh hook and pii. $SettingsFile = Join-Path $ClaudeDir "settings.json" try { $wireOut = & python (Join-Path $ProxyDir "wire.py") --name rolling-context --settings $SettingsFile 2>&1 foreach ($line in $wireOut) { Log "wire: $line" } } catch { Log "WARNING: wire.py failed to update settings.json: $_" } # --- Is the proxy actually SERVING? ------------------------------------------ # Not "does a PID file exist", and not even "is that PID alive". A crashed # proxy leaves its PID file behind and the OS is free to hand that number to an # unrelated process, so the liveness check says yes while nothing is listening: # the hook logs "Proxy already running" and every session that follows fails # with ConnectionRefused (issue #9). Ask the port instead — probe.py answers # "ours <version>", "foreign" or "down". $Probe = Join-Path $ScriptDir "probe.py" # Timeouts are passed as strings on purpose: a double renders through the # current culture, so on a comma-decimal locale 0.5 reaches python as "0,5". function Get-ProxyState([string]$timeout = "2") { try { (& python $Probe $Port $timeout | Select-Object -First 1) } catch { "down" } } function Get-ProxyPid([string]$timeout = "2") { try { (& python $Probe $Port $timeout "pid" | Select-Object -First 1) } catch { "" } } function Get-Decision([string]$timeout = "2") { # down | foreign | same | newer <v> | older <v> idle | older <v> busy <n> <how> try { [string](& python $Probe $Port $timeout "decide" $CurrentVersion | Select-Object -First 1) } catch { "down" } } function Wait-UntilIdle([int]$budget) { # Poll the decision until the running proxy has nothing in flight, for at # most $budget seconds. Returns the final decision. $deadline = (Get-Date).AddSeconds($budget) while ($true) { $d = Get-Decision "0.5" if (-not ($d -like "older * busy *")) { return $d } if ((Get-Date) -ge $deadline) { return $d } Start-Sleep -Milliseconds 500 } } function Test-IsOurProxy($processId) { # Identity, not just liveness — never kill a process that merely inherited # our old PID. if (-not $processId) { return $false } $p = Get-CimInstance Win32_Process -Filter "ProcessId=$processId" -ErrorAction SilentlyContinue return ($p -and $p.CommandLine -like "*server.py*") } function Stop-ProxyPid($processId, $source) { if (-not $processId) { return } if (-not (Get-Process -Id $processId -ErrorAction SilentlyContinue)) { return } if (Test-IsOurProxy $processId) { Log "Stopping proxy PID $processId ($source)" Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue } else { Log "PID $processId ($source) is alive but is not our proxy — recycled PID, leaving that process alone" } } function Clear-RecordedProxy([switch]$Serving) { # The PID from /health outranks the PID file: it comes from the process # actually holding the port, while the file is a copy that a crash, a lost # bind race or a manual start can leave pointing anywhere. if ($Serving) { Stop-ProxyPid (Get-ProxyPid) "reported by /health" } if (Test-Path $PidFile) { $savedPid = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1) if ($savedPid) { Stop-ProxyPid ([string]$savedPid).Trim() "from the PID file" } } Remove-Item $PidFile -Force -ErrorAction SilentlyContinue Remove-Item $VerFile -Force -ErrorAction SilentlyContinue } function Replace-RunningProxy { Clear-RecordedProxy -Serving # The old proxy owns the port until it actually exits. Starting on top of # it would just lose the bind and leave the old version serving while the # log claimed a restart. # Bounded by the clock, not by a pr - hooks/start-proxy.shGitHub
Read the script
#!/usr/bin/env bash # Ensure rolling context proxy is running # Pure stdlib — no venv needed, just python SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROXY_DIR="$SCRIPT_DIR/../proxy" PIDFILE="$HOME/.claude/rolling-context-proxy.pid" VERFILE="$HOME/.claude/rolling-context-proxy.version" HOOKLOG="$HOME/.claude/rolling-context-hook.log" PORT="${ROLLING_CONTEXT_PORT:-5588}" PROXY_URL="http://127.0.0.1:$PORT" CURRENT_VERSION=$(cat "$SCRIPT_DIR/../.claude-plugin/plugin.json" 2>/dev/null | grep '"version"' | head -1 | sed 's/.*"version".*"\(.*\)".*/\1/') log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$HOOKLOG" } # Detect Windows (git bash) if [[ "$(uname -s)" == MINGW* ]] || [[ "$(uname -s)" == MSYS* ]]; then IS_WINDOWS=true else IS_WINDOWS=false fi _python() { if [ "$IS_WINDOWS" = true ]; then echo "python" elif command -v python3 &>/dev/null; then echo "python3" else echo "python" fi } PYTHON_CMD=$(_python) log "Hook started. PROXY_DIR=$PROXY_DIR IS_WINDOWS=$IS_WINDOWS" # Wire ourselves into Claude Code via HTTPS_PROXY + NODE_EXTRA_CA_CERTS (NOT # ANTHROPIC_BASE_URL, which trips the Remote Control / GrowthBook gate). CA # generation, HTTPS_PROXY single-owner ownership, chaining with pii-proxy, # plugin defaults and stale-base_url cleanup all live in wire.py — one tested # implementation shared with the PowerShell hook and with pii-proxy. SETTINGS_FILE="$HOME/.claude/settings.json" WIRE_OUT=$($PYTHON_CMD "$PROXY_DIR/wire.py" --name rolling-context --settings "$SETTINGS_FILE" 2>&1) if [ $? -eq 0 ]; then while IFS= read -r line; do [ -n "$line" ] && log "wire:$line"; done <<< "$WIRE_OUT" else log "WARNING: wire.py failed to update settings.json: $WIRE_OUT" fi # Check if proxy is already running _kill_pid() { local pid="$1" if [ "$IS_WINDOWS" = true ]; then powershell.exe -Command "Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue" 2>/dev/null else # TERM first: proxies from 1.13.2 on drain their in-flight requests # before exiting. Escalate to KILL only once that grace is used up. kill "$pid" 2>/dev/null local waited=0 while [ "$waited" -lt 24 ] && kill -0 "$pid" 2>/dev/null; do sleep 0.25; waited=$((waited + 1)) done kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null fi } _pid_alive() { local pid="$1" if [ "$IS_WINDOWS" = true ]; then powershell.exe -Command "if (Get-Process -Id $pid -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" 2>/dev/null else kill -0 "$pid" 2>/dev/null fi } _is_our_proxy() { # Identity, not just liveness. `kill -0` answers "is SOME process wearing # this number", and after a crash the kernel hands the dead proxy's number # to whatever starts next. Killing on liveness alone would kill a stranger. local pid="$1" [ -n "$pid" ] || return 1 if [ "$IS_WINDOWS" = true ]; then powershell.exe -NoProfile -Command "\$p = Get-CimInstance Win32_Process -Filter \"ProcessId=$pid\" -ErrorAction SilentlyContinue; if (\$p -and \$p.CommandLine -like '*server.py*') { exit 0 } else { exit 1 }" 2>/dev/null elif [ -r "/proc/$pid/cmdline" ]; then tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q "server\.py" else ps -p "$pid" -o args= 2>/dev/null | grep -q "server\.py" fi } _stop_pid() { local pid="$1" source="$2" [ -n "$pid" ] || return 0 _pid_alive "$pid" || return 0 if _is_our_proxy "$pid"; then log "Stopping proxy PID $pid ($source)" _kill_pid "$pid" else log "PID $pid ($source) is alive but is not our proxy — recycled PID, leaving that process alone" fi } _clear_recorded_proxy() { # $1 = true when something is still serving on the port. The PID reported # by /health outranks the PID file: it comes from the process that is # actually holding the port, while the file is a copy that a crash, a lost # bind race or a manual start can leave pointing anywhere. if [ "$1" = "serving" ]; then _stop_pid "$(_probe_pid)" "reported by /health" fi _stop_pid "$(cat "$PIDFILE" 2>/dev/null | tr -d '[:space:]')" "from the PID file" rm -f "$PIDFILE" "$VERFILE" } _probe() { $PYTHON_CMD "$SCRIPT_DIR/probe.py" "$PORT" "${1:-2}" 2>/dev/null } _probe_pid() { $PYTHON_CMD "$SCRIPT_DIR/probe.py" "$PORT" "${1:-2}" pid 2>/dev/null | tr -d '[:space:]' } _decide() { # down | foreign | same | newer <v> | older <v> idle | older <v> busy <n> <how> $PYTHON_CMD "$SCRIPT_DIR/probe.py" "$PORT" "${1:-2}" decide "$CURRENT_VERSION" 2>/dev/null } _wait_until_idle() { # Poll the decision until the running proxy has nothing in flight, for # at most $1 seconds. Prints the final decision. local budget="$1" d SECONDS=0 while :; do d=$(_decide 0.5) case "$d" in "older "*" busy "*) ;; *) break ;; esac [ "$SECONDS" -ge "$budget" ] && break sleep 0.5 done echo "$d" } _replace_running_proxy() { _clear_recorded_proxy serving # The old proxy owns the port until it actually exits. Starting on top # of it would just lose the bind and leave the old version serving # while the log claimed a restart. # Bounded by the clock, not by a probe count: each probe costs an # interpreter start, and the SessionStart hook has 30s in total. SECONDS=0 while [ "$SECONDS" -lt 5 ]; do case "$(_probe 0.5)" in ours*) ;; *) return 0 ;; esac sleep 0.25 done case "$(_probe 0.5)" in ours*) log "ERROR: the old proxy is still serving on :$PORT and could not be stopped — not starting a second one." exit 0 ;; esac } # Ask the PORT, not the PID file. The PID file only records an intention to # run; the port is where "running" is either true or it is not. See probe.py # and issue #9 for the failure this replaces. # The p
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
A transparent proxy that gives Claude Code rolling context compression — old messages get automatically summarized while recent messages stay fully verbatim. You never hit the context wall, and you never lose important details. Zero config.
Repo: NodeNestor/claude-rolling-context

