From ab3efa2402f47e95d1f459a5860e41cdaa5ea114 Mon Sep 17 00:00:00 2001 From: johba Date: Thu, 19 Mar 2026 14:57:54 +0100 Subject: [PATCH] fix: replace fragile pane grep with Stop hook for idle detection (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Claude Code v2.1.79 permanently shows `❯` in the input area even while actively thinking, causing `monitor_phase_loop` to false-positive on idle detection and kill working sessions after 90 seconds - Replace `tmux capture-pane | grep ❯` with a Claude Code Stop hook (`lib/hooks/on-idle-stop.sh`) that writes a marker file only when Claude actually finishes responding - Hook is installed per-worktree in `.claude/settings.json` by `create_agent_session`; marker cleaned up on inject/kill ## Test plan - [x] Verified hook installs correctly in fresh worktree - [x] Verified marker file appears only after Claude finishes responding (not during active thinking) - [x] Verified live dev-agent session picks up fix and Claude works without being killed - [x] Verified `agent_inject_into_session` clears marker before new work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: openhands Reviewed-on: https://codeberg.org/johba/disinto/pulls/272 --- lib/agent-session.sh | 60 ++++++++++++++++++++++++++++++--------- lib/hooks/on-idle-stop.sh | 14 +++++++++ 2 files changed, 60 insertions(+), 14 deletions(-) create mode 100755 lib/hooks/on-idle-stop.sh diff --git a/lib/agent-session.sh b/lib/agent-session.sh index 0292680..2953802 100644 --- a/lib/agent-session.sh +++ b/lib/agent-session.sh @@ -31,6 +31,8 @@ agent_inject_into_session() { local text="$2" local tmpfile agent_wait_for_claude_ready "$session" 120 || true + # Clear idle marker — new work incoming + rm -f "/tmp/claude-idle-${session}.ts" tmpfile=$(mktemp /tmp/agent-inject-XXXXXX) printf '%s' "$text" > "$tmpfile" tmux load-buffer -b "agent-inject-$$" "$tmpfile" @@ -42,10 +44,42 @@ agent_inject_into_session() { } # Create a tmux session running Claude in the given workdir. +# Installs a Stop hook for idle detection (see monitor_phase_loop). # Returns 0 if session is ready, 1 otherwise. create_agent_session() { local session="$1" local workdir="${2:-.}" + + # Install Stop hook for idle detection: when Claude finishes a response, + # the hook writes a timestamp to a marker file. monitor_phase_loop checks + # this marker instead of fragile tmux pane scraping. + local idle_marker="/tmp/claude-idle-${session}.ts" + local hook_script="${FACTORY_ROOT}/lib/hooks/on-idle-stop.sh" + if [ -x "$hook_script" ]; then + mkdir -p "${workdir}/.claude" + local settings="${workdir}/.claude/settings.json" + local hook_cmd="${hook_script} ${idle_marker}" + if [ -f "$settings" ]; then + # Append our Stop hook to existing project settings + jq --arg cmd "$hook_cmd" ' + .hooks.Stop = (.hooks.Stop // []) + [{ + matcher: "", + hooks: [{type: "command", command: $cmd}] + }] + ' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings" + else + jq -n --arg cmd "$hook_cmd" '{ + hooks: { + Stop: [{ + matcher: "", + hooks: [{type: "command", command: $cmd}] + }] + } + }' > "$settings" + fi + fi + + rm -f "$idle_marker" tmux new-session -d -s "$session" -c "$workdir" \ "claude --dangerously-skip-permissions" 2>/dev/null sleep 1 @@ -66,10 +100,10 @@ inject_formula() { # Args: phase_file idle_timeout_secs callback_fn [session_name] # session_name — tmux session to health-check; falls back to $SESSION_NAME global # -# Idle prompt detection: if Claude returns to the ❯ prompt for 3 consecutive polls -# WITHOUT having written any phase signal, the session is killed and the callback is -# invoked with "PHASE:failed". This handles the case where Claude completes its work -# but skips the phase protocol entirely. +# Idle detection: uses a Stop hook marker file (written by lib/hooks/on-idle-stop.sh) +# to detect when Claude finishes responding without writing a phase signal. +# If the marker exists for 3 consecutive polls with no phase written, the session +# is killed and the callback invoked with "PHASE:failed". monitor_phase_loop() { local phase_file="$1" local idle_timeout="$2" @@ -124,19 +158,16 @@ monitor_phase_loop() { agent_kill_session "${_session}" return 0 fi - # Idle prompt detection: Claude finished without writing a phase signal. - # Only fires when current_phase is empty (no phase ever written). - # Note: tmux capture-pane captures the full visible pane area, not just the - # last line. Prior tool output containing ❯ (e.g. a zsh subshell prompt in - # Claude's output) could trigger a false positive — the same risk exists in - # agent_wait_for_claude_ready(). Requiring 3 consecutive polls (≥2 poll - # intervals of sustained idle) reduces but does not eliminate this risk. - if [ -z "$current_phase" ] && tmux has-session -t "${_session}" 2>/dev/null && \ - tmux capture-pane -t "${_session}" -p 2>/dev/null | grep -q '❯'; then + # Idle detection via Stop hook: the on-idle-stop.sh hook writes a marker + # file when Claude finishes a response. If the marker exists and no phase + # has been written, Claude returned to the prompt without following the + # phase protocol. 3 consecutive polls = confirmed idle (not mid-turn). + local idle_marker="/tmp/claude-idle-${_session}.ts" + if [ -z "$current_phase" ] && [ -f "$idle_marker" ]; then idle_pane_count=$(( idle_pane_count + 1 )) if [ "$idle_pane_count" -ge 3 ]; then _MONITOR_LOOP_EXIT="idle_prompt" - # Session is already killed before the callback is invoked. + # Session is killed before the callback is invoked. # Callbacks that handle PHASE:failed must not assume the session is alive. agent_kill_session "${_session}" if type "${callback}" &>/dev/null; then @@ -185,6 +216,7 @@ monitor_phase_loop() { agent_kill_session() { local session="${1:-}" [ -n "$session" ] && tmux kill-session -t "$session" 2>/dev/null || true + rm -f "/tmp/claude-idle-${session}.ts" } # Read the current phase from a phase file, stripped of whitespace. diff --git a/lib/hooks/on-idle-stop.sh b/lib/hooks/on-idle-stop.sh new file mode 100755 index 0000000..c8e4e5f --- /dev/null +++ b/lib/hooks/on-idle-stop.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# on-idle-stop.sh — Stop hook for dark-factory agent sessions. +# +# Called by Claude Code when it finishes a response. Writes a timestamp +# to a marker file so monitor_phase_loop can detect idle sessions +# without fragile tmux pane scraping. +# +# Usage (in .claude/settings.json): +# {"type": "command", "command": "this-script /tmp/claude-idle-SESSION.ts"} +# +# Args: $1 = marker file path + +cat > /dev/null # consume hook JSON from stdin +[ -n "${1:-}" ] && date +%s > "$1"