Skip to content

Commit 3ede3e1

Browse files
committed
fix: cleanup must run even when current hook is already present
The exact-match guard skipped the entire cleanup block when the current hook was found, even if stale duplicates coexisted. Now cleanup runs when the current hook is missing OR any stale copy exists. Also adds Test 7h (current + stale coexist) and tightens SessionEnd path assertion. Updates AGENTS.md to reflect the two-phase matching model (exact detect, substring cleanup).
1 parent f4259dd commit 3ede3e1

3 files changed

Lines changed: 69 additions & 20 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ process args as a reliable fallback.
7474
- Log files go to `~/.tmux/resurrect/assistant-{save,restore}.log` (truncated to 500 lines per run)
7575
- Process inspection uses `ps -eo pid=,ppid=` (not `pgrep -P` -- unreliable on macOS)
7676
- Agent detection matches binary names via `case` patterns in `detect_tool()`
77-
- Hook matching in jq uses `(.command // "") | contains("claude-session-track")`
78-
(not exact `==`) to tolerate quoting changes across versions and ensure backward
79-
compatibility. The `// ""` null-coalescing prevents crashes on hook entries that
80-
lack a `.command` field (e.g., URL-type hooks added by other tools)
77+
- Hook install uses two-phase matching: **exact equality** (`== $cmd`) to detect
78+
whether the current-path hook is already installed, and **substring match**
79+
(`contains("claude-session-track")`) to clean up stale copies left by path
80+
changes (e.g., Nix rebuilds). Cleanup runs when the current hook is missing OR
81+
stale copies exist. The `// ""` null-coalescing on `.command` prevents crashes
82+
on hook entries that lack a `.command` field (e.g., URL-type hooks), and
83+
`.hooks` is null-coalesced before mapping to handle entries with missing/null
84+
hooks arrays
8185
- Use `posix_quote()` from `lib-detect.sh` for any values sent to tmux panes
8286
via `send-keys` (safe for bash, zsh, fish, and other POSIX-ish shells)
8387
- Hook command paths use single quotes (`bash '${CURRENT_DIR}/hooks/...'`);

test/run-tests.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2040,6 +2040,57 @@ assert_eq "Null hooks: exactly 1 track hook installed" "1" "$null_track"
20402040
null_end=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select((.command // "") | contains("claude-session-cleanup"))] | length' "$HOME/.claude/settings.json")
20412041
assert_eq "Null hooks: exactly 1 cleanup hook installed" "1" "$null_end"
20422042
2043+
# --- Test 7h: Current + stale hook coexist (cleanup must still run) ---
2044+
#
2045+
# If both the current-path hook AND a stale-path duplicate are present
2046+
# (e.g. from manual editing or corruption), the cleanup block must still
2047+
# fire to remove the stale copy. Previously the exact-match guard would
2048+
# see the current hook, skip the block, and leave the stale duplicate.
2049+
2050+
echo ""
2051+
echo "=== Test 7h: Current + stale hook coexist ==="
2052+
echo ""
2053+
2054+
rm -f "$HOME/.claude/settings.json"
2055+
echo '{}' >"$HOME/.claude/settings.json"
2056+
2057+
# Inject both the CURRENT path and a STALE path for SessionStart and SessionEnd
2058+
current_track="bash '${REPO_DIR}/hooks/claude-session-track.sh'"
2059+
current_cleanup="bash '${REPO_DIR}/hooks/claude-session-cleanup.sh'"
2060+
tmp_dual=$(mktemp)
2061+
jq --arg cur_track "$current_track" --arg stale_track "$stale_track" \
2062+
--arg cur_cleanup "$current_cleanup" --arg stale_cleanup "$stale_cleanup" '
2063+
.hooks = {
2064+
"SessionStart": [
2065+
{"matcher": "", "hooks": [{"type": "command", "command": $cur_track}]},
2066+
{"matcher": "", "hooks": [{"type": "command", "command": $stale_track}]}
2067+
],
2068+
"SessionEnd": [
2069+
{"matcher": "", "hooks": [{"type": "command", "command": $cur_cleanup}]},
2070+
{"matcher": "", "hooks": [{"type": "command", "command": $stale_cleanup}]}
2071+
]
2072+
}
2073+
' "$HOME/.claude/settings.json" >"$tmp_dual" && mv "$tmp_dual" "$HOME/.claude/settings.json"
2074+
2075+
# Verify both are in place
2076+
dual_before=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
2077+
assert_eq "Dual: 2 track hooks present before cleanup" "2" "$dual_before"
2078+
2079+
# Run the plugin — must remove the stale copy, keep exactly 1
2080+
bash "$REPO_DIR/tmux-assistant-resurrect.tmux"
2081+
2082+
dual_after=$(jq '[.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track"))] | length' "$HOME/.claude/settings.json")
2083+
assert_eq "Dual: exactly 1 SessionStart hook after cleanup" "1" "$dual_after"
2084+
2085+
dual_cmd=$(jq -r '.hooks.SessionStart[]?.hooks[]? | select(.command | contains("claude-session-track")) | .command' "$HOME/.claude/settings.json")
2086+
assert_eq "Dual: surviving hook has current path" "$current_track" "$dual_cmd"
2087+
2088+
dual_end_after=$(jq '[.hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup"))] | length' "$HOME/.claude/settings.json")
2089+
assert_eq "Dual: exactly 1 SessionEnd hook after cleanup" "1" "$dual_end_after"
2090+
2091+
dual_end_cmd=$(jq -r '.hooks.SessionEnd[]?.hooks[]? | select(.command | contains("claude-session-cleanup")) | .command' "$HOME/.claude/settings.json")
2092+
assert_eq "Dual: surviving SessionEnd hook has current path" "$current_cleanup" "$dual_end_cmd"
2093+
20432094
# --- Test 8: strip_assistant_pane_contents() ---
20442095
20452096
suite "strip_pane_contents"

tmux-assistant-resurrect.tmux

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,12 @@ install_claude_hooks() {
5050
return
5151
fi
5252

53-
# Install SessionStart hook, refreshing a stale path if one exists.
54-
#
55-
# The earlier check matched on the substring "claude-session-track" and
56-
# skipped install if any hook contained it. That left STALE paths in
57-
# place across reinstalls — e.g. on Nix/NixOS, where each rebuild can
58-
# produce a new /nix/store hash for the plugin and the previous
59-
# derivation gets garbage-collected. Claude Code then ran a hook
60-
# pointing at a path that no longer existed and emitted
61-
# "SessionStart:clear hook error / bash: <gone-path>: No such file".
62-
#
63-
# New idempotency: skip only when the EXACT current command (path-
64-
# matched) is already installed. Otherwise filter out any prior
65-
# instance of this hook (any path) and add the current one. Each
66-
# tmux start now self-heals stale entries left by a previous install.
67-
if ! jq -e --arg cmd "$track_cmd" '.hooks.SessionStart[]?.hooks[]? | select((.command // "") == $cmd)' "$settings" >/dev/null 2>&1; then
53+
# Install SessionStart hook, refreshing stale paths if any exist.
54+
# Skip only when the current command is present AND no stale copies remain.
55+
local has_current_track has_stale_track
56+
has_current_track=$(jq --arg cmd "$track_cmd" '[.hooks.SessionStart[]?.hooks[]? | select((.command // "") == $cmd)] | length' "$settings" 2>/dev/null || echo 0)
57+
has_stale_track=$(jq --arg cmd "$track_cmd" '[.hooks.SessionStart[]?.hooks[]? | select(((.command // "") | contains("claude-session-track")) and ((.command // "") != $cmd))] | length' "$settings" 2>/dev/null || echo 0)
58+
if [ "$has_current_track" = "0" ] || [ "$has_stale_track" != "0" ]; then
6859
local tmp
6960
tmp=$(mktemp)
7061
jq --arg cmd "$track_cmd" '
@@ -84,7 +75,10 @@ install_claude_hooks() {
8475
fi
8576

8677
# Install SessionEnd hook (same self-healing pattern as SessionStart).
87-
if ! jq -e --arg cmd "$cleanup_cmd" '.hooks.SessionEnd[]?.hooks[]? | select((.command // "") == $cmd)' "$settings" >/dev/null 2>&1; then
78+
local has_current_cleanup has_stale_cleanup
79+
has_current_cleanup=$(jq --arg cmd "$cleanup_cmd" '[.hooks.SessionEnd[]?.hooks[]? | select((.command // "") == $cmd)] | length' "$settings" 2>/dev/null || echo 0)
80+
has_stale_cleanup=$(jq --arg cmd "$cleanup_cmd" '[.hooks.SessionEnd[]?.hooks[]? | select(((.command // "") | contains("claude-session-cleanup")) and ((.command // "") != $cmd))] | length' "$settings" 2>/dev/null || echo 0)
81+
if [ "$has_current_cleanup" = "0" ] || [ "$has_stale_cleanup" != "0" ]; then
8882
local tmp
8983
tmp=$(mktemp)
9084
jq --arg cmd "$cleanup_cmd" '

0 commit comments

Comments
 (0)