The tmux display-message approach had edge cases: it could succeed outside tmux, fail on first iteration, or behave differently depending on tmux socket state. Replace with AGENT_LOOP_ACTIVE env var exported by loop.sh. CC sessions spawned by the loop inherit it; interactive CC sessions don't. Simple, no external dependencies, no race conditions.
63 lines
2.4 KiB
Bash
63 lines
2.4 KiB
Bash
#!/bin/bash
|
|
# Stop hook management for the agent loop.
|
|
#
|
|
# The Stop hook sends SIGINT to the loop's parent process when a claude session
|
|
# finishes. This is how claude signals "I'm done" back to the bash loop —
|
|
# the Ralph pattern (ghuntley.com/ralph).
|
|
#
|
|
# Without this hook, claude would exit to an interactive prompt instead of
|
|
# returning control to the loop script.
|
|
#
|
|
# IMPORTANT: The hook is scoped to only fire inside the agent-loop tmux session.
|
|
# Without this guard, ANY Claude Code session opened in the same project directory
|
|
# would pick up the hook and kill its own parent shell on exit.
|
|
|
|
SETTINGS_FILE="${PROJECT_ROOT}/.claude/settings.local.json"
|
|
|
|
# The hook checks AGENT_LOOP_ACTIVE before killing. This env var is exported by
|
|
# loop.sh and inherited by CC sessions it spawns. Interactive CC sessions in the
|
|
# same project won't have it set, so the hook is a no-op for them.
|
|
HOOK_COMMAND='[ "${AGENT_LOOP_ACTIVE:-}" = "1" ] && kill -INT $PPID || true'
|
|
|
|
install_hooks() {
|
|
if [ ! -f "$SETTINGS_FILE" ]; then
|
|
mkdir -p "$(dirname "$SETTINGS_FILE")"
|
|
echo '{}' > "$SETTINGS_FILE"
|
|
fi
|
|
|
|
if command -v jq &>/dev/null; then
|
|
jq --arg cmd "$HOOK_COMMAND" \
|
|
'.hooks.Stop = [{"matcher": "", "hooks": [{"type": "command", "command": $cmd}]}]' \
|
|
"$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
|
else
|
|
LOOP_HOOK_CMD="$HOOK_COMMAND" LOOP_SETTINGS="$SETTINGS_FILE" python3 -c "
|
|
import json, os
|
|
p = os.environ['LOOP_SETTINGS']
|
|
cmd = os.environ['LOOP_HOOK_CMD']
|
|
s = json.load(open(p)) if os.path.exists(p) else {}
|
|
s.setdefault('hooks', {})['Stop'] = [{'matcher': '', 'hooks': [{'type': 'command', 'command': cmd}]}]
|
|
json.dump(s, open(p, 'w'), indent=2)
|
|
"
|
|
fi
|
|
log "Stop hook installed"
|
|
}
|
|
|
|
remove_hooks() {
|
|
if [ -f "$SETTINGS_FILE" ]; then
|
|
if command -v jq &>/dev/null; then
|
|
jq 'del(.hooks.Stop)' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
|
jq 'if .hooks == {} then del(.hooks) else . end' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
|
|
else
|
|
LOOP_SETTINGS="$SETTINGS_FILE" python3 -c "
|
|
import json, os
|
|
p = os.environ['LOOP_SETTINGS']
|
|
s = json.load(open(p))
|
|
s.get('hooks', {}).pop('Stop', None)
|
|
if not s.get('hooks'): s.pop('hooks', None)
|
|
json.dump(s, open(p, 'w'), indent=2)
|
|
"
|
|
fi
|
|
log "Stop hook removed"
|
|
fi
|
|
}
|