feat(hooks): cache-handoff Stop hook wakes idle claude for a hand-off - #526
feat(hooks): cache-handoff Stop hook wakes idle claude for a hand-off#526lroolle wants to merge 1 commit into
Conversation
- 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 <noreply@anthropic.com>
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0547e6f1a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # process behind. | ||
| if [[ -f "$pidfile" ]]; then | ||
| old=$(cat "$pidfile" 2>/dev/null) | ||
| [[ -n "$old" && "$old" != "$$" ]] && kill "$old" 2>/dev/null |
There was a problem hiding this comment.
Terminate the superseded hook's sleep child
When another Stop arrives during the 50-minute delay, old identifies the Bash hook process, but sleep is a separate child. Killing Bash leaves that child reparented to PID 1 and sleeping for the full duration; repeated turns therefore accumulate exactly the sleeper processes this gate is intended to eliminate. Use a trap or process-group-aware cancellation that also terminates the child.
Useful? React with 👍 / 👎.
| if [[ -f "$pidfile" ]]; then | ||
| old=$(cat "$pidfile" 2>/dev/null) | ||
| [[ -n "$old" && "$old" != "$$" ]] && kill "$old" 2>/dev/null |
There was a problem hiding this comment.
Verify stale PID ownership before sending a signal
In a persistent container, this pidfile remains after the hook fires or exits through a later gate, so a subsequent Stop can encounter it after that PID has been recycled. In that case this unconditional kill terminates an unrelated process owned by the same user, potentially including Claude or an active tool. Remove the pidfile on exit and/or record and validate process identity before signaling it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in Claude Code Stop hook to “rewake” an idle session before the ~1h prompt cache expires, prompting the model to write a hand-off file while context re-read is still cheap. This fits deva’s container-first workflow by shipping the hook as a copy-in repo asset (no automatic mutation of host ~/.claude), plus hermetic gate tests and CI wiring.
Changes:
- Add
hooks/cache-handoff.shasync Stop hook with idle-gating (transcript-growth abort, per-session sleeper supersede, once-per-position dedup). - Add hermetic gate tests (
scripts/test-cache-handoff.sh) and run them in CI. - Document installation/behavior in
hooks/README.mdanddocs/advanced-usage.md, and note the feature inCHANGELOG.md/DEV-LOGS.md.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/test-cache-handoff.sh | New hermetic smoke tests covering the hook’s gate/supersede/dedup behavior. |
| hooks/README.md | New documentation for hook installation, config wiring, knobs, and caveats. |
| hooks/cache-handoff.sh | New Stop hook implementation that sleeps then exits 2 to rewake only when still idle. |
| docs/advanced-usage.md | Adds an “Cache Handoff Hook” section pointing to the hook and docs. |
| DEV-LOGS.md | Adds a dev log entry documenting rationale, behavior, and risk caveats. |
| CHANGELOG.md | Adds an Unreleased “Added” entry for the new hook feature. |
| .github/workflows/ci.yml | Runs the new cache-handoff smoke test in CI. |
Comments suppressed due to low confidence (1)
hooks/README.md:67
- “container-local, nothing persists or leaks to the host” is only accurate when Claude Code is running inside a container (as deva typically does). If someone uses this hook outside deva, the state dir is host-local. Consider wording this as “local to the environment running Claude Code” to keep it accurate in both cases.
State is per-session pid + done files; container-local, nothing
persists or leaks to the host.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| mkdir -p "$STATE_DIR" || exit 0 | ||
| pidfile="$STATE_DIR/$session.pid" | ||
| donefile="$STATE_DIR/$session.done" |
| if [[ -f "$pidfile" ]]; then | ||
| old=$(cat "$pidfile" 2>/dev/null) | ||
| [[ -n "$old" && "$old" != "$$" ]] && kill "$old" 2>/dev/null | ||
| fi |
| 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 | ||
|
|
|
|
||
| ``` | ||
| CC_HANDOFF_AFTER_SEC seconds idle before firing (default 3000) | ||
| CC_HANDOFF_STATE_DIR pid/done state dir (default $TMPDIR/cc-cache-handoff) |
Summary
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 — headless/container sessions (deva's whole mode) never get one.
This ships hooks/cache-handoff.sh, an opt-in Stop hook using the asyncRewake exit-2 wake primitive (verified in claude 2.1.220): 50min after the last Stop, if the session is still idle, it wakes the model to write a hand-off file while the cache re-read is still nearly free.
Never interrupts running work — three gates:
New hooks/ dir follows the skills/ precedent: repo asset, copy-in install, zero wiring. Deliberately not wired into deva.sh — ~/.claude is host-mounted; same non-mutation call as the statusline non-redirect.
Verification
Risk
Rides internal unflagged claude settings fields (asyncRewake, rewakeMessage, rewakeSummary). A future claude release can drop them; failure mode is the hook never wakes anything. Caveat documented in hooks/README.md.
Closes #525
🤖 Generated with Claude Code