From c0547e6f1a6dc943248299cf4af7a64dd9002754 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Tue, 28 Jul 2026 23:04:56 -0700 Subject: [PATCH] feat(hooks): cache-handoff Stop hook wakes idle claude for a hand-off - new hooks/ dir: opt-in copy-in Claude Code assets, skills/-style - cache-handoff.sh sleeps 50min after Stop, exits 2 (asyncRewake) to wake the still-idle session while the 1h prompt cache is warm and have it write a hand-off file - three gates: transcript-growth abort, per-session pidfile supersede, once-per-transcript-position dedup; fails closed if claude drops the internal asyncRewake fields - not wired into deva.sh: ~/.claude is host-mounted, deva keeps its hands off it; install is documented copy-in per workspace - scripts/test-cache-handoff.sh: 6 hermetic gate cases, wired into CI - docs: advanced-usage section + hooks/README.md Close #525 Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++ CHANGELOG.md | 11 ++++ DEV-LOGS.md | 8 +++ docs/advanced-usage.md | 24 +++++++ hooks/README.md | 77 ++++++++++++++++++++++ hooks/cache-handoff.sh | 82 ++++++++++++++++++++++++ scripts/test-cache-handoff.sh | 116 ++++++++++++++++++++++++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 hooks/README.md create mode 100755 hooks/cache-handoff.sh create mode 100755 scripts/test-cache-handoff.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b18a54..f28d75a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,6 +189,12 @@ jobs: set -euo pipefail ./scripts/test-version-targets.sh + - name: Smoke cache-handoff hook gates + shell: bash + run: | + set -euo pipefail + ./scripts/test-cache-handoff.sh + - name: Smoke Chrome bridge entrypoint symlink shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec3b45..2b1d705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- cache-handoff hook (#525): opt-in Claude Code Stop hook under new + `hooks/` dir that wakes an idle session ~50min in — while the 1h + prompt cache is still warm — and has it write a hand-off file (goal, + state, decisions, next action). Uses the asyncRewake exit-2 wake + primitive verified in claude 2.1.220; never interrupts a running + turn (transcript-growth abort, per-session sleeper supersede, + once-per-position dedup). Copy-in install like `skills/` — deva does + not touch `~/.claude`. Gates covered by + `scripts/test-cache-handoff.sh` in CI. + ## [0.18.1] - 2026-07-28 ### Fixed diff --git a/DEV-LOGS.md b/DEV-LOGS.md index d90710c..45e1425 100644 --- a/DEV-LOGS.md +++ b/DEV-LOGS.md @@ -13,6 +13,14 @@ - Minimal markdown markers, no unnecessary formatting, minimal emojis. - Reference issue numbers in the format `#` for easy linking. +# [2026-07-28] Dev Log: cache-handoff Stop hook #525 +- Why: idle claude sessions burn the 1h prompt cache with nothing written down — the built-in away-summary needs terminal blur and skips past 0.9x TTL, so headless/container sessions (deva's whole mode) never get one. Binary dig (claude 2.1.220) found the only idle-wake primitive: command hook with asyncRewake:true exiting 2 injects a priority-next notification. +- What: + - new `hooks/` dir (skills/-style copy-in assets): `cache-handoff.sh` sleeps 50min after Stop, exits 2 with a hand-off prompt on stderr only if still idle. Three gates: transcript-growth abort (turn in flight), pidfile supersede (async hooks aren't deduplicated — every Stop spawns a sleeper), donefile dedup (once per transcript position). Knobs: CC_HANDOFF_AFTER_SEC, CC_HANDOFF_STATE_DIR. + - deliberately NOT wired into deva.sh: ~/.claude is host-mounted; same non-mutation call as the statusline non-redirect. Install is documented copy-in to workspace .claude/. + - `scripts/test-cache-handoff.sh`: 6 hermetic gate cases, in CI. Docs section in advanced-usage.md + hooks/README.md. +- Result: walk away mid-task, come back to a written hand-off instead of a dead cache. Caveat on record: asyncRewake/rewakeMessage/rewakeSummary are internal unflagged fields — a claude release can break this silently; the hook then just never wakes anything (fails closed). + # [2026-07-28] Dev Log: home dir chown race bricks containers #506 - Why: intermittent `env: 'claude': Permission denied` on fresh containers. /home/deva stuck at build UID 1001 mode 750 (noble HOME_MODE) after remap to host UID — user can't traverse its own home. usermod's implicit home-tree chown walks live host mounts (~/.claude churning under concurrent sessions), aborts mid-walk with rc=12 AFTER updating passwd; shadow chowns the top dir last, so it never gets fixed. The 7511464 whitelist chowns subdirs, never $DEVA_HOME itself. Latent since 5807889 dropped the recursive home chown; only bites when the walk races live mounts, which is why sibling containers were fine. - What: explicit non-recursive `chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"` in setup_nonroot_user, after the usermod block, using the adapted DEVA_UID so the usermod-failed-entirely variant stays consistent. Devlog with full forensics in docs/devlog/20260728-home-dir-chown-race.org. Verified by fault injection: stub usermod (passwd updated, chown skipped, exit 12) reproduces the brick unpatched, comes out clean patched. diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index a2f0fa9..22a5b1a 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -255,3 +255,27 @@ deva.sh gemini --auth-with ~/keys/gcp-service-account.json ``` Deva mounts the file onto the agent's expected credential path. It does not need to dump a directory full of backup junk into the container to make that work. + +## Cache Handoff Hook + +Claude Code's prompt cache dies after 1h idle. Walk away mid-task and +the model's working state dies with it; the next turn re-reads +everything at full price. + +`hooks/cache-handoff.sh` is an opt-in Stop hook that wakes an idle +session at ~50min and has it write a hand-off file while the re-read is +still nearly free. It never interrupts a running turn. + +Copy-in install, per workspace: + +```bash +mkdir -p .claude/hooks +cp hooks/cache-handoff.sh .claude/hooks/ +``` + +Then add the hook block to `.claude/settings.json`. Full wiring, +knobs, and caveats (it rides an undocumented claude internal) in +[hooks/README.md](https://github.com/thevibeworks/deva/blob/main/hooks/README.md). + +Deva does not install this for you. `~/.claude` is your host config; +deva keeps its hands off it. diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..f17fc52 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,77 @@ +# hooks + +Opt-in Claude Code hooks. Like `skills/`: repo files you copy in +yourself. Deva never writes into `~/.claude` -- it is bind-mounted from +the host, and your settings are yours. + +## cache-handoff.sh + +Claude Code keeps the main-thread prompt cache warm for 1h. When you +walk away mid-task and come back later, the cache is dead and the next +turn re-reads the whole context at full price -- and whatever the model +was holding in its head is gone with it. + +This hook wakes an idle session ~50min in (while the cache is still +warm, i.e. the re-read is nearly free) and has the model write a +hand-off to a file: goal, state, decisions, next action. It uses the +`asyncRewake` Stop-hook mechanism (verified against claude 2.1.220): +exit 2 from an async hook injects a priority-next notification that +wakes an idle session. + +It never interrupts running work: it only fires when the transcript has +not grown since the Stop that scheduled it, only the newest sleeper per +session survives, and it fires at most once per transcript position. + +### Install (per workspace) + +```bash +mkdir -p "$ws/.claude/hooks" +cp hooks/cache-handoff.sh "$ws/.claude/hooks/" +chmod +x "$ws/.claude/hooks/cache-handoff.sh" +``` + +Then merge into `$ws/.claude/settings.json`: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cache-handoff.sh", + "asyncRewake": true, + "timeout": 3300, + "rewakeMessage": "Idle hand-off (prompt cache expiring):", + "rewakeSummary": "Prompt cache expiring - writing hand-off" + } + ] + } + ] + } +} +``` + +The `timeout` must exceed the sleep (default 3000s); hooks without it +get killed at 600s. + +### Knobs + +``` +CC_HANDOFF_AFTER_SEC seconds idle before firing (default 3000) +CC_HANDOFF_STATE_DIR pid/done state dir (default $TMPDIR/cc-cache-handoff) +``` + +State is per-session pid + done files; container-local, nothing +persists or leaks to the host. + +### Caveats + +- `asyncRewake`, `rewakeMessage`, `rewakeSummary` are internal, + undocumented settings fields. They work in 2.1.220 and are not + feature-gated, but a future claude release can break them silently. +- The built-in away-summary does something similar but requires + terminal blur and skips past 0.9x cache TTL. This hook covers the + idle-but-focused case (and headless/container sessions, which never + blur). diff --git a/hooks/cache-handoff.sh b/hooks/cache-handoff.sh new file mode 100755 index 0000000..7d853a2 --- /dev/null +++ b/hooks/cache-handoff.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Wake Claude for a hand-off while the 1h prompt cache is still warm. +# +# Runs as an asyncRewake Stop hook: it detaches, sleeps through the idle +# gap, and exits 2 to wake the model only if the session is still idle +# and this is still the newest pending sleeper for the session. +# +# Behavior verified against claude 2.1.220: +# - main-thread prompt cache TTL is 1h (5m for background queries, +# and during usage overage). +# - a command hook with asyncRewake:true that exits 2 injects a +# priority-next task notification, which wakes an idle session. +# stderr wins over stdout for the payload. +# - the built-in away-summary only fires on terminal blur and skips +# past 0.9x TTL; an idle-but-focused session gets nothing. +# Fire at 50min, not 60: leaves the model room to finish before the +# cache dies. + +set -uo pipefail + +FIRE_AFTER="${CC_HANDOFF_AFTER_SEC:-3000}" +STATE_DIR="${CC_HANDOFF_STATE_DIR:-${TMPDIR:-/tmp}/cc-cache-handoff}" + +input=$(cat) +read -r active session transcript <<<"$( + printf '%s' "$input" | jq -r '[(.stop_hook_active // false), .session_id, (.transcript_path // "")] | @tsv' +)" + +# The rewake itself marks the next turn stop-hook-active. Never chain +# off our own wake. +[[ "$active" == "true" ]] && exit 0 +[[ -z "$session" || "$session" == "null" ]] && exit 0 + +mkdir -p "$STATE_DIR" || exit 0 +pidfile="$STATE_DIR/$session.pid" +donefile="$STATE_DIR/$session.done" + +size_of() { + if [[ -f "$1" ]]; then + wc -c <"$1" | tr -d ' ' + else + echo 0 + fi +} +before=$(size_of "$transcript") + +# Supersede any older sleeper for this session. Claude Code does not +# deduplicate async hooks, so every Stop would otherwise leave a +# process behind. +if [[ -f "$pidfile" ]]; then + old=$(cat "$pidfile" 2>/dev/null) + [[ -n "$old" && "$old" != "$$" ]] && kill "$old" 2>/dev/null +fi +echo $$ >"$pidfile" + +sleep "$FIRE_AFTER" + +# A newer Stop claimed the session while we slept. +[[ "$(cat "$pidfile" 2>/dev/null)" == "$$" ]] || exit 0 +# A turn is in flight (started but not yet stopped) -- do not interrupt. +[[ "$(size_of "$transcript")" == "$before" ]] || exit 0 +# Already handed off at this exact transcript position. +[[ "$(cat "$donefile" 2>/dev/null)" == "$before" ]] && exit 0 + +echo "$before" >"$donefile" + +cat >&2 <<'EOF' +This session has been idle for ~50 minutes. The 1h prompt cache is close +to expiring, so this is the last cheap read of the full context. + +Write a hand-off now, then stop. Do not start new work. + +Cover, in plain prose: + - the goal, and where the current task actually stands + - decisions made and what they cost / ruled out + - what was verified against reality vs. still assumed + - the one next action, concrete enough to execute cold + +Persist it to a file so it survives the cache and this context. Then +reply with one or two sentences saying where you put it. +EOF +exit 2 diff --git a/scripts/test-cache-handoff.sh b/scripts/test-cache-handoff.sh new file mode 100755 index 0000000..8ce4bdb --- /dev/null +++ b/scripts/test-cache-handoff.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Hermetic tests for hooks/cache-handoff.sh gate logic. +# Scratch state dir, zero/short sleeps, no claude involved. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +HOOK="$REPO_ROOT/hooks/cache-handoff.sh" + +PASS=0 +FAIL=0 + +pass() { + PASS=$((PASS + 1)) + echo "PASS: $1" +} + +fail() { + FAIL=$((FAIL + 1)) + echo "FAIL: $1" +} + +TMP="$(mktemp -d)" +cleanup() { + [[ -n "${SLEEPER_PID:-}" ]] && kill "$SLEEPER_PID" 2>/dev/null + rm -rf "$TMP" +} +trap cleanup EXIT + +STATE="$TMP/state" +TRANSCRIPT="$TMP/transcript.jsonl" +printf 'line one\n' >"$TRANSCRIPT" + +hook_input() { + local active="$1" session="$2" + printf '{"stop_hook_active":%s,"session_id":%s,"transcript_path":"%s"}' \ + "$active" "$session" "$TRANSCRIPT" +} + +run_hook() { + local after="$1" input="$2" rc=0 + printf '%s' "$input" | CC_HANDOFF_AFTER_SEC="$after" \ + CC_HANDOFF_STATE_DIR="$STATE" "$HOOK" 2>"$TMP/stderr" || rc=$? + echo "$rc" +} + +# 1. stop_hook_active=true must not schedule anything (no self-chain) +rc="$(run_hook 0 "$(hook_input true '"s1"')")" +if [[ "$rc" == "0" && ! -f "$STATE/s1.pid" ]]; then + pass "stop_hook_active skips without writing state" +else + fail "stop_hook_active: rc=$rc pidfile=$([[ -f "$STATE/s1.pid" ]] && echo yes || echo no)" +fi + +# 2. missing session_id must skip +rc="$(run_hook 0 "$(hook_input false null)")" +if [[ "$rc" == "0" ]]; then + pass "null session_id skips" +else + fail "null session_id: rc=$rc" +fi + +# 3. idle session fires: exit 2, hand-off prompt on stderr, donefile set +rc="$(run_hook 0 "$(hook_input false '"s1"')")" +size="$(wc -c <"$TRANSCRIPT" | tr -d ' ')" +if [[ "$rc" == "2" ]] && grep -q "hand-off" "$TMP/stderr" && + [[ "$(cat "$STATE/s1.done")" == "$size" ]]; then + pass "idle session fires exit 2 with hand-off prompt" +else + fail "idle fire: rc=$rc done=$(cat "$STATE/s1.done" 2>/dev/null || echo none)" +fi + +# 4. same transcript position must not fire twice +rc="$(run_hook 0 "$(hook_input false '"s1"')")" +if [[ "$rc" == "0" ]]; then + pass "dedup at same transcript position" +else + fail "dedup: rc=$rc" +fi + +# 5. transcript growth during the sleep aborts the wake +rc=0 +printf '%s' "$(hook_input false '"s2"')" | CC_HANDOFF_AFTER_SEC=1 \ + CC_HANDOFF_STATE_DIR="$STATE" "$HOOK" 2>/dev/null & +HOOK_PID=$! +# The hook captures the transcript size just before writing its pidfile; +# wait for the pidfile so the append below lands after the baseline read. +for _ in $(seq 1 50); do + [[ -f "$STATE/s2.pid" ]] && break + sleep 0.1 +done +printf 'a turn started\n' >>"$TRANSCRIPT" +wait "$HOOK_PID" || rc=$? +if [[ "$rc" == "0" && ! -f "$STATE/s2.done" ]]; then + pass "transcript growth aborts the wake" +else + fail "growth abort: rc=$rc done=$([[ -f "$STATE/s2.done" ]] && echo yes || echo no)" +fi + +# 6. a newer Stop supersedes the older sleeper (kills it via pidfile) +sleep 30 & +SLEEPER_PID=$! +echo "$SLEEPER_PID" >"$STATE/s3.pid" +rc="$(run_hook 0 "$(hook_input false '"s3"')")" +sleep 0.2 +if [[ "$rc" == "2" ]] && ! kill -0 "$SLEEPER_PID" 2>/dev/null; then + pass "newer sleeper kills the superseded one" +else + fail "supersede: rc=$rc old sleeper alive=$(kill -0 "$SLEEPER_PID" 2>/dev/null && echo yes || echo no)" +fi +SLEEPER_PID="" + +echo "=== Results ===" +echo "PASS: $PASS" +echo "FAIL: $FAIL" +[[ "$FAIL" -eq 0 ]] || exit 1