Skip to content

Instantly share code, notes, and snippets.

@bojanrajkovic
Created April 25, 2026 02:40
Show Gist options
  • Select an option

  • Save bojanrajkovic/32712ac9ee3e9630f338bb2d1978eb3d to your computer and use it in GitHub Desktop.

Select an option

Save bojanrajkovic/32712ac9ee3e9630f338bb2d1978eb3d to your computer and use it in GitHub Desktop.
Claude Code UserPromptSubmit hook: warn/block on stale prompt cache
#!/bin/bash
# UserPromptSubmit hook: block when the prompt cache is stale, warn when close.
# Anchored to the last `type: "user"` entry in the transcript — those are the
# cache-refresh events on Anthropic's side. Falls back to file mtime on parse
# failure. Fails open on any error.
set -e
INPUT=$(cat)
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')
[ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ] && exit 0
# Bypass sentinel: if the prompt contains <break_claude_cache>, skip the TTL
# check and tell Claude to ignore the token. UserPromptSubmit hooks can't
# rewrite the prompt, so the token leaks through — additionalContext explains it.
PROMPT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty')
if printf '%s' "$PROMPT" | grep -q '<break_claude_cache>'; then
jq -nc '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: "The <break_claude_cache> token in the user prompt is a local-hook cache-bypass sentinel. Treat it as a no-op and ignore it when responding."}}'
exit 0
fi
# TTL from declared Claude Code env vars (1h opt-in, else 5m default).
if [ "$ENABLE_PROMPT_CACHING_1H" = "true" ] && [ "$FORCE_PROMPT_CACHING_5M" != "true" ]; then
TTL=3600
else
TTL=300
fi
WARN=$(( TTL * 3 / 5 ))
# Anchor on the last user entry that corresponds to an actual API call:
# real prompts have .permissionMode, tool_results have .toolUseResult.
# Local-command metadata (e.g. /reload-plugins) has neither — skip it.
LAST_TS=$(jq -rs '[.[] | select(.type == "user" and (.permissionMode != null or .toolUseResult != null))] | last.timestamp // empty' "$TRANSCRIPT" 2>/dev/null || true)
ts_to_epoch() {
local ts=${1%.*}; ts=${ts%Z}
date -j -u -f "%Y-%m-%dT%H:%M:%S" "$ts" +%s 2>/dev/null
}
LAST_EPOCH=""
[ -n "$LAST_TS" ] && LAST_EPOCH=$(ts_to_epoch "$LAST_TS")
if [ -z "$LAST_EPOCH" ]; then
LAST_EPOCH=$(stat -f %m "$TRANSCRIPT" 2>/dev/null || stat -c %Y "$TRANSCRIPT" 2>/dev/null || echo 0)
fi
[ "$LAST_EPOCH" -eq 0 ] && exit 0
AGE=$(( $(date +%s) - LAST_EPOCH ))
if [ "$AGE" -ge "$TTL" ]; then
jq -nc --arg msg "⛔ Prompt cache stale: ${AGE}s since last refresh (TTL=${TTL}s). /clear or start a new session, or include <break_claude_cache> in the prompt to bypass." \
'{decision: "block", reason: $msg}'
exit 0
fi
if [ "$AGE" -ge "$WARN" ]; then
jq -nc --arg msg "⚠️ Prompt cache is ${AGE}s old (TTL=${TTL}s). Consider wrapping up or /clear-ing before the next turn." \
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $msg}}'
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment