diff --git a/lib/hooks.sh b/lib/hooks.sh index 0a1d875..0ecf4c0 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -1,19 +1,50 @@ #!/bin/bash -# Stop hook management for Claude Code loop continuation. +# Stop hook management for the agent loop. # -# NOTE: Hooks are currently no-ops. The loop uses `claude --print` (non-interactive), -# which runs to completion and exits naturally — no Stop hook is needed to signal -# iteration boundaries. The install/remove interface is preserved so that a future -# interactive mode can be added without changing loop.sh's call sites. +# 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). # -# If interactive mode is added, the hook mechanism will need redesign: `kill -INT $PPID` -# targets the hook runner's parent (Claude Code), not loop.sh. A sentinel-file or -# named-pipe approach would be more reliable. +# Without this hook, claude would exit to an interactive prompt instead of +# returning control to the loop script. + +SETTINGS_FILE="${PROJECT_ROOT}/.claude/settings.local.json" install_hooks() { - : # no-op — see note above + if [ ! -f "$SETTINGS_FILE" ]; then + mkdir -p "$(dirname "$SETTINGS_FILE")" + echo '{}' > "$SETTINGS_FILE" + fi + + if command -v jq &>/dev/null; then + jq '.hooks.Stop = [{"matcher": "", "hooks": [{"type": "command", "command": "kill -INT $PPID || true"}]}]' \ + "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + else + python3 -c " +import json, os +p = '$SETTINGS_FILE' +s = json.load(open(p)) if os.path.exists(p) else {} +s.setdefault('hooks', {})['Stop'] = [{'matcher': '', 'hooks': [{'type': 'command', 'command': 'kill -INT \$PPID || true'}]}] +json.dump(s, open(p, 'w'), indent=2) +" + fi + log "Stop hook installed" } remove_hooks() { - : # no-op — see note above + 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 + python3 -c " +import json +s = json.load(open('$SETTINGS_FILE')) +s.get('hooks', {}).pop('Stop', None) +if not s.get('hooks'): s.pop('hooks', None) +json.dump(s, open('$SETTINGS_FILE', 'w'), indent=2) +" + fi + log "Stop hook removed" + fi } diff --git a/loop.sh b/loop.sh index ebefa46..07e73aa 100755 --- a/loop.sh +++ b/loop.sh @@ -188,40 +188,33 @@ fi # --- Agent runner --- # Runs a prompt through the selected AI tool. -# Two modes: -# Interactive (default when TTY available): runs claude in full interactive mode. -# The user sees the complete CC session (tool calls, file edits, etc.) in the terminal. -# No output capture — state is tracked via prd.json and .verdict file. -# Headless (no TTY or LOOP_HEADLESS=true): uses claude --print for fully autonomous operation. -# Output is captured to a temp file for verdict parsing. # -# The function prints captured output to stdout (headless) or nothing (interactive). +# Interactive (default): Pipes prompt to claude WITHOUT --print. +# This gives the full interactive CC UI — tool calls, file edits, etc. +# A Stop hook (installed at startup) sends SIGINT to the loop when claude +# finishes, which returns control to the while loop for the next iteration. +# State is tracked via files (prd.json, .verdict), not stdout. +# +# Headless (LOOP_HEADLESS=true): Uses claude --print for CI/background. +# Output captured to file for verdict parsing. run_agent() { local prompt="$1" - local role="${2:-}" # "generator" or "evaluator" — used for verdict file + local role="${2:-}" - # Clean up any previous verdict file rm -f "$LOOP_DIR/.verdict" - # Determine whether we can run interactively - local has_tty=false - if [ "${LOOP_HEADLESS:-false}" != "true" ] && { true > /dev/tty; } 2>/dev/null; then - has_tty=true - fi - - # Run in subshell so a non-zero exit from the AI tool doesn't kill the loop. local agent_exit=0 - if [ "$has_tty" = true ]; then - # --- Interactive mode --- - # Run claude directly in the terminal — full interactive UI visible. - # No output capture. State tracked via files (prd.json, .verdict). + if [ "${LOOP_HEADLESS:-false}" != "true" ]; then + # --- Interactive mode (Ralph pattern) --- + # Pipe prompt to claude without --print — full interactive UI. + # The Stop hook handles session exit. ( case "$TOOL" in claude) - claude --dangerously-skip-permissions "$prompt" + printf '%s\n' "$prompt" | claude --dangerously-skip-permissions ;; amp) - amp --dangerously-allow-all "$prompt" + printf '%s\n' "$prompt" | amp --dangerously-allow-all ;; *) log "ERROR: Unknown tool '$TOOL'" @@ -230,7 +223,9 @@ run_agent() { esac ) || agent_exit=$? - # In interactive mode, read verdict from file if evaluator wrote one + sleep 2 # Brief pause between sessions + + # Read verdict from file if evaluator wrote one if [ "$role" = "evaluator" ] && [ -f "$LOOP_DIR/.verdict" ]; then cat "$LOOP_DIR/.verdict" fi