Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions plugins/claudex/hooks/stop-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<none>}"

# 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)
Expand Down
20 changes: 12 additions & 8 deletions plugins/claudex/scripts/start-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions plugins/claudex/scripts/state-helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,27 @@ claudex_find_active_loop() {
printf '%s' "$latest"
}

# claudex_find_active_loop_for_session <session_id>
# 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 ' '
Expand Down
21 changes: 21 additions & 0 deletions plugins/claudex/tests/platform-validation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Expand Down