From ca54df863a0ae4636e0307210f8acd9f3182e14f Mon Sep 17 00:00:00 2001 From: Raphael Gindrat Date: Thu, 7 May 2026 17:59:23 +0200 Subject: [PATCH] fix: scope Stop hook + start-loop to current session_id Two related bugs hit users running multiple Claude Code sessions in the same project: 1. Cross-session pollution. The Stop hook used `claudex_find_active_loop`, which returns the most-recent .state file in the project regardless of which session created it. So if session A is in the middle of a loop and session B ends a turn, session B gets BLOCKed with session A's round-N instructions. Two terminals quickly become unintelligible. 2. False concurrency lockout. start-loop.sh refused to start if ANY non-terminal .state file existed in the project, even one owned by a different session. Two unrelated topics in two terminals were impossible. The state file already records `session_id` (line 179 of start-loop.sh) but neither the hook nor the start gate ever reads it. This patch wires session_id through: - state-helpers.sh: new `claudex_find_active_loop_for_session ` helper that iterates state files in mtime order and returns the first one whose `session_id` field matches. Empty/unknown sid -> non-zero. The legacy `claudex_find_active_loop` is kept untouched for backwards compatibility (used in tests, possibly by external callers). - stop-hook.sh: extracts `session_id` from the JSON hook input via python3 (with $CLAUDE_SESSION_ID as fallback), then uses the scoped lookup. If session_id can't be determined, fail-open (approve) so older Claude Code versions that don't pass session_id keep working. - start-loop.sh: the existing anti-double-launch loop now compares each active state's session_id against $CLAUDE_SESSION_ID. Only refuses if THIS session already has an active loop; loops in other sessions are allowed to coexist. - tests/platform-validation.sh: new section 11b adds 5 checks for the session-scoped lookup helper (empty arg, no loops, two sessions routed to their own state, unknown sid). All 55 tests pass (was 50). No behavior change for single-session use. Known remaining limitation (not addressed here): two concurrent loops in the *same* working directory still both target `PLAN.md` and would clobber each other. A `--plan-file ` flag would solve it cleanly but is a larger UX change; happy to do it in a follow-up if maintainers agree on the shape. --- plugins/claudex/hooks/stop-hook.sh | 25 +++++++++++++++++--- plugins/claudex/scripts/start-loop.sh | 20 +++++++++------- plugins/claudex/scripts/state-helpers.sh | 21 ++++++++++++++++ plugins/claudex/tests/platform-validation.sh | 21 ++++++++++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/plugins/claudex/hooks/stop-hook.sh b/plugins/claudex/hooks/stop-hook.sh index 7e7b7b1..09dccec 100755 --- a/plugins/claudex/hooks/stop-hook.sh +++ b/plugins/claudex/hooks/stop-hook.sh @@ -62,11 +62,30 @@ else fi log "Hook fired. Input bytes: ${#HOOK_INPUT}" -# Find active loop. +# Extract session_id from hook input so we can scope active-loop lookup to the +# current Claude Code session. Without this, sessions in the same project +# pollute each other (session A's loop blocks session B's turns). +CURRENT_SID="" +CURRENT_SID=$(printf '%s' "$HOOK_INPUT" \ + | python3 -c 'import json,sys +try: + print(json.load(sys.stdin).get("session_id","")) +except Exception: + pass' 2>/dev/null) +if [ -z "$CURRENT_SID" ]; then + CURRENT_SID="${CLAUDE_SESSION_ID:-}" +fi +log "Current session_id: ${CURRENT_SID:-}" + +# Find active loop scoped to THIS session. Fail-open if we can't determine the +# session — better to let Claude exit than to trap it on stale state from +# another session. ACTIVE_STATE="" -ACTIVE_STATE=$(claudex_find_active_loop 2>/dev/null) +if [ -n "$CURRENT_SID" ]; then + ACTIVE_STATE=$(claudex_find_active_loop_for_session "$CURRENT_SID" 2>/dev/null) +fi if [ -z "$ACTIVE_STATE" ] || [ ! -f "$ACTIVE_STATE" ]; then - approve "no active loop" + approve "no active loop for this session (sid=${CURRENT_SID:-none})" fi REVIEW_ID=$(basename "$ACTIVE_STATE" .state) diff --git a/plugins/claudex/scripts/start-loop.sh b/plugins/claudex/scripts/start-loop.sh index 862ba16..d0fa025 100755 --- a/plugins/claudex/scripts/start-loop.sh +++ b/plugins/claudex/scripts/start-loop.sh @@ -110,24 +110,28 @@ mkdir -p "$CLAUDEX_STATE_DIR" || exit 3 # Sweep stale loops first (anything older than 15 min by default). claudex_sweep_stale -# Refuse to start if another loop is genuinely active. +# Refuse to start if another loop is genuinely active IN THIS SESSION. # State files are kept on disk for audit even after a loop completes or is # cancelled, so we check the phase to decide if a loop is still running. # Active phases: drafting, reviewing, revising. Terminal: done, cancelled, errored. +# Loops from OTHER sessions are allowed to coexist (each session is scoped by +# its session_id; the Stop hook only triggers on its own session's state file). +CURRENT_SID="${CLAUDE_SESSION_ID:-unknown}" for state in "$CLAUDEX_STATE_DIR"/*.state; do [ -f "$state" ] || continue state_phase=$(claudex_state_read_field "$state" "phase") case "$state_phase" in done|cancelled|errored|"") - # Terminal phase or unparseable; not an active loop. - ;; - *) - active_id=$(basename "$state" .state) - echo "Another claudex loop is already active: $active_id (phase: $state_phase)" >&2 - echo "Run /claudex:cancel to abort it, or /claudex:rollback to force-clean." >&2 - exit 1 + continue ;; esac + state_sid=$(claudex_state_read_field "$state" "session_id") + if [ "$state_sid" = "$CURRENT_SID" ]; then + active_id=$(basename "$state" .state) + echo "Another claudex loop is already active in this session: $active_id (phase: $state_phase)" >&2 + echo "Run /claudex:cancel to abort it, or /claudex:rollback to force-clean." >&2 + exit 1 + fi done # Generate review_id. diff --git a/plugins/claudex/scripts/state-helpers.sh b/plugins/claudex/scripts/state-helpers.sh index 4748e14..e6e4f2f 100755 --- a/plugins/claudex/scripts/state-helpers.sh +++ b/plugins/claudex/scripts/state-helpers.sh @@ -141,6 +141,27 @@ claudex_find_active_loop() { printf '%s' "$latest" } +# claudex_find_active_loop_for_session +# Returns the most-recent state file whose `session_id` field matches the +# argument. Empty argument or no match → returns 1 (no output). +# This is the session-scoped variant used by the Stop hook so multiple Claude +# sessions in the same project don't pollute each other. +claudex_find_active_loop_for_session() { + local sid="$1" + [ -n "$sid" ] || return 1 + [ -d "$CLAUDEX_STATE_DIR" ] || return 1 + local f file_sid + while IFS= read -r f; do + [ -f "$f" ] || continue + file_sid=$(claudex_state_read_field "$f" "session_id") + if [ "$file_sid" = "$sid" ]; then + printf '%s' "$f" + return 0 + fi + done < <(ls -t "$CLAUDEX_STATE_DIR"/*.state 2>/dev/null) + return 1 +} + claudex_count_active_loops() { [ -d "$CLAUDEX_STATE_DIR" ] || { echo 0; return 0; } ls "$CLAUDEX_STATE_DIR"/*.state 2>/dev/null | wc -l | tr -d ' ' diff --git a/plugins/claudex/tests/platform-validation.sh b/plugins/claudex/tests/platform-validation.sh index 7e0fe2e..7507dd3 100755 --- a/plugins/claudex/tests/platform-validation.sh +++ b/plugins/claudex/tests/platform-validation.sh @@ -162,6 +162,27 @@ cd - >/dev/null rm -rf "$TMP" unset CLAUDEX_STATE_DIR +section "11b. Session-scoped active loop finder" +TMP=$(mktemp -d) +cd "$TMP" +export CLAUDEX_STATE_DIR=".claude/claudex" +mkdir -p "$CLAUDEX_STATE_DIR" +check "empty session arg returns non-zero" bash -c "! claudex_find_active_loop_for_session ''" +check "no loops returns non-zero" bash -c "! claudex_find_active_loop_for_session SID_X" +claudex_state_write "$CLAUDEX_STATE_DIR/loopA.state" "phase: drafting +session_id: SID_A" +sleep 1 +claudex_state_write "$CLAUDEX_STATE_DIR/loopB.state" "phase: drafting +session_id: SID_B" +matchA=$(claudex_find_active_loop_for_session "SID_A") +check "scoped lookup finds SID_A loop" bash -c "echo '$matchA' | grep -q loopA" +matchB=$(claudex_find_active_loop_for_session "SID_B") +check "scoped lookup finds SID_B loop" bash -c "echo '$matchB' | grep -q loopB" +check "scoped lookup with unknown sid returns non-zero" bash -c "! claudex_find_active_loop_for_session SID_Z" +cd - >/dev/null +rm -rf "$TMP" +unset CLAUDEX_STATE_DIR + section "12. Personas helper" check "personas.sh exists" test -f "$PLUGIN_ROOT/scripts/personas.sh" check "personas sources cleanly" bash -c "source '$PLUGIN_ROOT/scripts/personas.sh'"