diff --git a/docs/HOOKS-EXPLAINED.md b/docs/HOOKS-EXPLAINED.md index 693b920..3658f24 100644 --- a/docs/HOOKS-EXPLAINED.md +++ b/docs/HOOKS-EXPLAINED.md @@ -567,7 +567,8 @@ labelled `claude`, the name of an agent it supports natively (measured on herdr as tmux session options — `@hwip_herdr_pane`, `@hwip_herdr_socket`, `@hwip_herdr_bin` (the values of `HERDR_PANE_ID`, `HERDR_SOCKET_PATH`, `HERDR_BIN_PATH`) — and clears them on an attach from outside herdr. The hook reads -them with one `tmux display-message`. No options means no herdr, so the hook does +them with one `tmux display-message`, one value per line: `display-message -p` prints +any other control character as a literal octal escape (`\x1f` -> `\037`, tmux 3.5a). No options means no herdr, so the hook does nothing, including for plain `claude` in a herdr pane, which herdr detects on its own, and for a CLI that does not write the options yet. It also no-ops for a headless `claude -p` (`CLAUDE_CODE_ENTRYPOINT=sdk-cli`): one started from a shell in @@ -594,7 +595,9 @@ it; whether such panes exist, and carry a signal to tell them apart, is unverifi **Cost.** PostToolUse runs on every tool call, so its common case exits after reading one state file in `$HWIP_STATE_DIR` (`herdr-`, per-user like the other state files), before any tmux call. A state equal -to the last one sent is not resent, and `herdr` runs detached (as an array, so a +to the last one sent is not resent — unless herdr has restarted since, which drops every +report it held: herdr recreates its socket when it starts, so a socket newer than the +state file sends the state again (a `test -nt`, no fork, so the tool-call path stays cheap). And `herdr` runs detached (as an array, so a `herdr` path with a space works). The one blocking call, `tmux display-message`, is cut at 2 s by `timeout`, `gtimeout` or perl (gate.sh `hwip_bound_prefix`, the same bound the other hooks put on their calls), well inside the hook's 5 s cap. `--seq` is diff --git a/hooks/hwip-herdr.sh b/hooks/hwip-herdr.sh index c9d3328..88a7d98 100755 --- a/hooks/hwip-herdr.sh +++ b/hooks/hwip-herdr.sh @@ -18,7 +18,8 @@ # (a permission prompt or a question was answered). # # Never slows Claude Code down: herdr runs in the background, failures are -# ignored, and a state equal to the last one reported is not sent again. +# ignored, and a state equal to the last one reported is not sent again — unless +# herdr restarted since, which drops every report it held. # Hook input: only SessionStart's `source` is read, so only `start` keeps it. Every # other event drains it — with `cat`, not a builtin `read`: PostToolUse input carries @@ -44,15 +45,21 @@ event="${1:-}" state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/hwip" state_file="$state_dir/herdr-${TMUX_PANE//[^A-Za-z0-9]/_}" -last_seq=0 last_state='' last_pane='' -[ -f "$state_file" ] && read -r last_seq last_state last_pane <"$state_file" -# A seq that is not a number is no seq: `$((last_seq + 1))` would run `a[$(cmd)]`. The other two -# fields are only compared as strings. +last_seq=0 last_state='' last_pane='' last_sock='' +# The socket is last: `read` gives the final name the rest of the line, so a path with spaces survives. +[ -f "$state_file" ] && read -r last_seq last_state last_pane last_sock <"$state_file" +# A seq that is not a number is no seq: `$((last_seq + 1))` would run `a[$(cmd)]`. The other +# fields are only compared as strings or tested as a path. case "$last_seq" in '' | *[!0-9]*) last_seq=0 ;; esac +# herdr forgets every report when it restarts, and it recreates its socket when it starts, so a +# socket newer than the last report means that report is gone: send the state again. A `test` +# builtin — no fork on the hot path below. +restarted='' +[ -n "$last_sock" ] && [ "$last_sock" -nt "$state_file" ] && restarted=1 last_state='' # The hot path: every tool call lands here. Skip the tmux round trip unless it # can change something. -[ "$event" = tool ] && [ "$last_state" != blocked ] && exit 0 +[ "$event" = tool ] && [ -z "$restarted" ] && [ "$last_state" != blocked ] && exit 0 command -v tmux >/dev/null 2>&1 || exit 0 # A tmux that never answers must not eat the hook's 5 s cap (plugin.json): bound @@ -62,10 +69,11 @@ command -v tmux >/dev/null 2>&1 || exit 0 # nothing never loads it. . "$(dirname "${BASH_SOURCE[0]}")/gate.sh" hwip_bound_prefix 2 -# Unit separator, not tab: tab is IFS whitespace, so an empty field would collapse into its neighbour. -sep=$'\x1f' -IFS="$sep" read -r pane sock bin < <( - "${HWIP_BOUND[@]}" tmux display-message -p -t "$TMUX_PANE" "#{@hwip_herdr_pane}${sep}#{@hwip_herdr_socket}${sep}#{@hwip_herdr_bin}" 2>/dev/null +# One option per line. Not tab: tab is IFS whitespace, so an empty field would collapse into its +# neighbour. Not another control character: `display-message -p` prints one as a literal octal +# escape (`\x1f` -> `\037`, tmux 3.5a), so the three values arrived as one. +{ IFS= read -r pane; IFS= read -r sock; IFS= read -r bin; } < <( + "${HWIP_BOUND[@]}" tmux display-message -p -t "$TMUX_PANE" $'#{@hwip_herdr_pane}\n#{@hwip_herdr_socket}\n#{@hwip_herdr_bin}' 2>/dev/null ) [ -n "${pane:-}" ] && [ -n "${sock:-}" ] || exit 0 [ -n "${bin:-}" ] && [ -x "$bin" ] || bin="$(command -v herdr)" || exit 0 @@ -94,7 +102,7 @@ if [ "$state" = released ]; then rm -f "$state_file" set -- pane release-agent "$pane" --source hwip --agent 'hwip cc' --seq "$seq" else - printf '%s %s %s\n' "$seq" "$state" "$pane" >"$state_file" + printf '%s %s %s %s\n' "$seq" "$state" "$pane" "$sock" >"$state_file" set -- pane report-agent "$pane" --source hwip --agent 'hwip cc' --state "$state" --seq "$seq" fi diff --git a/tests/test-hwip-herdr.sh b/tests/test-hwip-herdr.sh index 0280b2c..357be35 100644 --- a/tests/test-hwip-herdr.sh +++ b/tests/test-hwip-herdr.sh @@ -18,9 +18,11 @@ STUB_DIR="$WORK/stub" LOG="$WORK/herdr.log" mkdir -p "$STUB_DIR" +# Like tmux 3.5a `display-message -p`: a control character other than tab or +# newline comes out as a literal octal escape (`\x1f` -> `\037`). cat > "$STUB_DIR/tmux" <<'EOF' #!/bin/bash -printf '%b\n' "$OPTS" +printf '%b\n' "$OPTS" | perl -pe 's/([\x00-\x08\x0b-\x1f\x7f])/sprintf("\\%03o", ord $1)/ge' EOF cat > "$STUB_DIR/herdr" < "$SOCK" +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run idle +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run idle +check "same state, same herdr: not resent" 1 "$(calls)" +touch -t 200001010000 "$WORK/tmp/hwip/herdr-_1"; touch -t 200101010000 "$SOCK" +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run idle +check "same state after a herdr restart: resent" 2 "$(calls)" +reset +: > "$SOCK" +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run working +touch -t 200001010000 "$WORK/tmp/hwip/herdr-_1"; touch -t 200101010000 "$SOCK" +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run tool +check "tool call after a herdr restart: working resent" "2 $SOCK pane report-agent wF:p1 --source hwip --agent hwip cc --state working --seq N" "$(calls) $(last)" +OPTS="wF:p1\n$SOCK\n$STUB_DIR/herdr" run tool +check "next tool call: not resent" 2 "$(calls)" + echo "reattach" reset OPTS="$IN_HERDR" run working -OPTS="wF:p9\x1f/sock\x1f$STUB_DIR/herdr" run working +OPTS="wF:p9\n/sock\n$STUB_DIR/herdr" run working check "same state, new herdr pane: reported there" "/sock pane report-agent wF:p9 --source hwip --agent hwip cc --state working --seq N" "$(last)" echo "bounds" # herdr installed under a path with a space: the hook runs it as an array. reset SP="$WORK/with space"; mkdir -p "$SP"; cp "$STUB_DIR/herdr" "$SP/herdr" -OPTS="wF:p1\x1f/sock\x1f$SP/herdr" run working +OPTS="wF:p1\n/sock\n$SP/herdr" run working check "herdr path with a space: reported" "/sock pane report-agent wF:p1 --source hwip --agent hwip cc --state working --seq N" "$(last)" # A tmux that never answers is cut at 2 s, by whichever bound the machine has: @@ -147,9 +169,9 @@ fi echo "options" # An empty or non-executable bin option: herdr from PATH (the stub dir is on PATH). reset -OPTS="wF:p1\x1f/sock\x1f" run working +OPTS="wF:p1\n/sock\n" run working check "no bin option: herdr from PATH" "/sock pane report-agent wF:p1 --source hwip --agent hwip cc --state working --seq N" "$(last)" -OPTS="wF:p1\x1f/sock\x1f$WORK/not-there" run idle +OPTS="wF:p1\n/sock\n$WORK/not-there" run idle check "bin option not executable: herdr from PATH" "/sock pane report-agent wF:p1 --source hwip --agent hwip cc --state idle --seq N" "$(last)" # start re-claims the pane even when the state did not change (a restarted session). reset @@ -170,7 +192,7 @@ check "start from a fresh session: claims the pane" "/sock pane report-agent wF: echo "empty fields" reset -OPTS="\x1f/sock\x1f$STUB_DIR/herdr" run working +OPTS="\n/sock\n$STUB_DIR/herdr" run working check "empty pane with a socket: not read as pane=socket" 0 "$(calls)" echo