Hooks per runtime
What this layer does
Phase 3 wires push --auto and pull --auto into every runtime's session-lifecycle so Joe never thinks about sync. Stop hook fires push, SessionStart hook fires pull. Runtimes without native hooks (Gemini, OpenClaw) get covered by a 5-minute cron tick.
Files involved
~/.claude/settings.json— Claude Code Stop + SessionStart arrays.~/.codex/hooks.json— Codex Stop + SessionStart entries.crontab -e— Gemini/OpenClaw cron-tick entry plus the per-Joe
snappy-os doctor self-test at 30 minutes past every 6 hours.
bin/wire-hooks.js— idempotent installer; backs up
settings.json to ~/.claude/_backups/settings-<ts>.json before every change.
state/bin/sync/cron-tick.sh— runspull --autothenpush --auto
if any local OR remote change since last tick.
state/bin/auto-regen.sh— Stop hook body for the PID loop.state/bin/sync-runtimes.ts— Stop hook body that regenerates
per-runtime instruction files when CLAUDE.md changes.
Canonical hook order on Claude Code Stop
hooks.Stop = [auto-regen-skills.sh, sync-runtimes.ts, push.sh --auto]
bin/wire-hooks.js enforces this order. Any pre-existing entries in the wrong order get re-ordered after backup. Push runs LAST so any PID rewrite or runtime regen lands in the same upload.
Per-runtime mechanism
| Runtime | Mechanism | Stop | SessionStart |
|---|---|---|---|
| Claude Code | Native | append push --auto to canonical Stop array | new SessionStart entry runs pull --auto |
| Codex CLI | Native | append push --auto | append pull --auto to existing SessionStart matcher |
| Gemini CLI | Cron | covered by cron-tick.sh | covered |
| OpenClaw | Cron | covered by cron-tick.sh | covered |
| Cursor | n/a (rules-file only) | n/a | n/a |
| Windsurf | n/a | n/a | n/a |
Idempotency
Re-running bootstrap N times must leave hook arrays the same length. wire-hooks.js dedupes by exact command string match. Smoke step C.1 / step 14 asserts:
LEN1=$(npx snappy-os && jq '.hooks.Stop | length' ~/.claude/settings.json)
LEN2=$(npx snappy-os && jq '.hooks.Stop | length' ~/.claude/settings.json)
[ "$LEN1" = "$LEN2" ] || echo FAIL
Operational gotchas
- Bootstrap MUST back up
settings.jsonbefore any edit. The backup
filename includes the timestamp so multiple runs leave a trail.
- Codex
~/.codex/hooks.jsonuses amatcherblock on SessionStart;
append, do not overwrite, the matcher's command list.
- Cron drift: if a Joe machine sleeps through a tick, the next tick
catches up — sync is idempotent, never additive.
push --autodebounce lock at/tmp/snappy-os-debounce-<runtime>.lock
short-circuits within 60s. A stop hook firing twice in a session produces one push + one debounced row.
How to verify it's working
jq '.hooks.Stop' ~/.claude/settings.jsonshows the canonical 3-entry
array in the documented order.
- Triggering a Claude Code session end produces a fresh row in
state/log/sync-events.ndjson with trigger: "stop-hook".
crontab -l | grep snappy-osshows bothcron-tick.shand
snappy-os doctor entries.
- Running
npx snappy-osthree times leaves
jq '.hooks.Stop | length' ~/.claude/settings.json unchanged.