Skip to content
Development
Hook

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.

From plugin
rolling-context
314 commands1 hook

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
Read hooks/hooks.json

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.

Ships withrolling-context

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.

Get the whole plugin
Stats
31
Stars
8
Forks
Active
Maintenance
Python
Language
MIT
License
14d ago
Last commit
6mo ago
Created

Repo: NodeNestor/claude-rolling-context