Skip to content
Merged
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
7 changes: 5 additions & 2 deletions docs/HOOKS-EXPLAINED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<pane>`, 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
Expand Down
30 changes: 19 additions & 11 deletions hooks/hwip-herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
38 changes: 30 additions & 8 deletions tests/test-hwip-herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
#!/bin/bash
Expand All @@ -37,7 +39,7 @@ check() {
else echo " ✗ $desc"; echo " expected: $expected"; echo " actual: $actual"; FAIL=$((FAIL + 1)); FAILED_TESTS+=("$desc"); fi
}

IN_HERDR="wF:p1\x1f/sock\x1f$STUB_DIR/herdr"
IN_HERDR="wF:p1\n/sock\n$STUB_DIR/herdr"

# Run the hook, then wait for the detached herdr call (or give up quietly).
run() {
Expand All @@ -60,7 +62,7 @@ echo "no-ops"
reset
OPTS="$IN_HERDR" T='' run working
check "outside tmux: nothing sent" 0 "$(calls)"
OPTS="\x1f\x1f" run working
OPTS="\n\n" run working
check "tmux session without herdr options: nothing sent" 0 "$(calls)"
OPTS="$IN_HERDR" run tool
check "tool call with nothing blocked: nothing sent" 0 "$(calls)"
Expand Down Expand Up @@ -106,17 +108,37 @@ check "non-numeric seq in the state file: not executed" 0 "$([ -e "$WORK/PWNED"
check "non-numeric seq in the state file: still reported" "/sock pane report-agent wF:p1 --source hwip --agent hwip cc --state working --seq N" "$(last)"
check "state file rewritten with a number" 1 "$(awk '{print ($1 ~ /^[0-9]+$/) ? 1 : 0}' "$WORK/tmp/hwip/herdr-_1")"

echo "herdr restart"
# herdr drops every report when it restarts and recreates its socket when it starts. A state equal
# to the last one reported is resent once the socket is newer than that report.
reset
SOCK="$WORK/herdr.sock"; : > "$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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading