Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions DEV-LOGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
- Minimal markdown markers, no unnecessary formatting, minimal emojis.
- Reference issue numbers in the format `#<issue-number>` 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.
Expand Down
24 changes: 24 additions & 0 deletions docs/advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
77 changes: 77 additions & 0 deletions hooks/README.md
Original file line number Diff line number Diff line change
@@ -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).
82 changes: 82 additions & 0 deletions hooks/cache-handoff.sh
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +21 to +33
mkdir -p "$STATE_DIR" || exit 0
pidfile="$STATE_DIR/$session.pid"
donefile="$STATE_DIR/$session.done"
Comment on lines +34 to +36

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +50 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

fi
Comment on lines +50 to +53
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
116 changes: 116 additions & 0 deletions scripts/test-cache-handoff.sh
Original file line number Diff line number Diff line change
@@ -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
Loading