From 93b7ffc564c9e1ad10cfff1ec7004be2c4e1f9c5 Mon Sep 17 00:00:00 2001 From: tak Date: Sun, 18 Oct 2026 20:41:03 +0900 Subject: [PATCH] fix(herdr): report hwip cc to herdr again and survive its restarts tmux 3.5a's `display-message -p` prints a unit separator as the literal `\037`, so the three herdr options arrived as one value, the socket read as empty, and the hook exited before every report: no `hwip cc` pane ever showed in herdr. Read the options one per line instead, and make the test's tmux stub escape control characters the way tmux does. herdr forgets every report when it restarts, and the hook skips a state equal to the last one it sent, so an idle agent stayed missing. herdr recreates its socket on start: keep the socket in the state file and resend when it is newer than the last report (a `test -nt`, no fork). --- docs/HOOKS-EXPLAINED.md | 7 +++++-- hooks/hwip-herdr.sh | 30 +++++++++++++++++++----------- tests/test-hwip-herdr.sh | 38 ++++++++++++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 21 deletions(-) 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