Skip to content

Commit ba04f0a

Browse files
authored
feat(overlay): full command display, smart regex proposal, tmux notification passthrough (#24)
* feat(overlay): full command display, smart regex proposal, tmux notification passthrough - overlay-dialog: wrap long commands at terminal width instead of truncating with "..." - propose-rule: when compound command has uncovered segments (via new PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS env var), target the first uncovered segment's first word instead of the full command's first word - pre-tool-use: compute uncovered segments via new compute_unallowed_segments helper in common.sh (filters out readonly auto-allowed + allow/ask matched) - notification: write OSC 777 to /dev/tty instead of stdout (stdout is the hook's JSON response); wrap in DCS tmux; ... ST passthrough when inside tmux so Ghostty can receive the notification through the tmux popup * fix(compound): readonly segments count as covered in compound allow matching `go test ./... | tail -50` with user rule `^go` used to fall through to overlay because `tail -50` had no allow rule. But tail IS readonly. Fix: before calling match_all_segments on compound Bash commands, pre-filter out segments that are readonly-auto-allowed (match readonly regex + paths inside cwd/allowed_dirs). The filter is gated on has_redirect=false so `cat file > /tmp/out && ls` still falls through when has_redirect would have blocked the readonly step. Also adds regression tests: - readonly segment covers for compound allow - filter respects has_redirect guard - 2>&1 fd duplication does not trigger has_redirect * fix(overlay): cross-session lock at user-scope with stale-lock recovery The overlay queue lock was scoped to $TMPDIR, which on macOS is often a per-process folder under /var/folders/... - not shared across CC sessions. Two simultaneous CC sessions could each open their own tmux popup, breaking the serialization. Also: when a hook was SIGKILLed (OOM, timeout) the lock directory persisted forever, blocking every subsequent overlay until manually cleared. Fixes: - Lock lives at $(passthru_user_home)/passthru-overlay.lock.d, guaranteed shared across CC sessions of the same user. - New _OVERLAY_LOCK_STALE_AFTER threshold (default 180s, > overlay timeout + margin): if the existing lock's mtime is older than that, clear it and retry. Checked every ~2s during wait. - Default lock timeout raised from 90s to 180s to match typical user response time across multiple queued sessions. * docs(claude.md): cross-session overlay lock, native-dialog cascade, hook ordering - correct stale 75s hook timeout reference (actual is 300s) - new "Overlay queue lock (cross-session)" section: user-home lock path, macOS TMPDIR caveat, stale-lock recovery - new "Interaction with CC's native permission system" section: decision cascade that explains why "ask" triggers native dialog, and how multi-plugin hook ordering causes passthru to see either original or wrapped (rtk) commands - new "Notifications on overlay prompt" section: /dev/tty requirement and tmux DCS passthrough wrapping - document PASSTHRU_OVERLAY_LOCK_TIMEOUT, PASSTHRU_OVERLAY_LOCK_STALE_AFTER, and PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS env vars * chore(release): v0.7.0
1 parent 5beee3d commit ba04f0a

10 files changed

Lines changed: 506 additions & 44 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

CLAUDE.md

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ commands/
1818
log.md /passthru:log slash command (prompt-based)
1919
overlay.md /passthru:overlay slash command (wraps scripts/overlay-config.sh)
2020
hooks/
21-
hooks.json registers PreToolUse (timeout 75s, matcher "*"), PostToolUse +
21+
hooks.json registers PreToolUse (timeout 300s, matcher "*"), PostToolUse +
2222
PostToolUseFailure (timeout 10s each, matcher "*"), and
2323
SessionStart (timeout 5s, no matcher) handlers
2424
common.sh shared library. Functions:
@@ -118,7 +118,10 @@ Variables the plugin reads at runtime. Most are test-only overrides; a couple (`
118118
* `PASSTHRU_OVERLAY_TEST_ANSWER` - short-circuit the interactive keypress loop in `overlay-dialog.sh`. Accepts `yes_once|yes_always|no_once|no_always|cancel`. Used exclusively by `tests/overlay.bats` + `tests/hook_handler.bats` to exercise every branch without pseudo-tty gymnastics. Never set by the hook in production.
119119
* `PASSTHRU_OVERLAY_TOOL_NAME` - tool name passed into the overlay dialog. Hook propagates the inbound `tool_name` field verbatim.
120120
* `PASSTHRU_OVERLAY_TOOL_INPUT_JSON` - tool input JSON (stringified) passed into the overlay dialog. Hook propagates the inbound `tool_input` field verbatim. The dialog and `overlay-propose-rule.sh` parse it for the suggested-rule screen.
121-
* `PASSTHRU_OVERLAY_TIMEOUT` - seconds to wait for a user response inside the overlay. Default 60. If the user does not respond in time, the overlay exits without writing a verdict and the hook treats the prompt as cancelled (falls through to the native dialog). Setting below 60 is fine; setting above requires also raising the PreToolUse hook timeout (currently 75s).
121+
* `PASSTHRU_OVERLAY_TIMEOUT` - seconds to wait for a user response inside the overlay. Default 60. If the user does not respond in time, the overlay exits without writing a verdict and the hook treats the prompt as cancelled (falls through to the native dialog). Setting below 60 is fine; setting above requires also raising the PreToolUse hook timeout (currently 300s).
122+
* `PASSTHRU_OVERLAY_LOCK_TIMEOUT` - seconds to wait for another CC session's overlay to release the user-scope queue lock. Default 180. On timeout, the hook emits the ask fallback (native dialog). See the "Overlay queue lock" section below.
123+
* `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` - mtime age threshold in seconds after which an existing overlay lock is considered abandoned and auto-cleared. Default 180. Protects against SIGKILLed hooks leaving zombie locks.
124+
* `PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS` - newline-separated list of compound Bash segments that are NOT covered by readonly auto-allow or by any allow/ask rule. Set by `pre-tool-use.sh` before invoking the overlay. Read by `overlay-propose-rule.sh` so that "yes/no always" proposals target only the uncovered portion instead of the full command's first word.
122125
* `PASSTHRU_WRITE_LOCK_TIMEOUT` - seconds `scripts/write-rule.sh` and `scripts/remove-rule.sh` wait for the user-scope mkdir lock. Default 5. See the "Write-wrapper locking" section below.
123126

124127
## How tests run
@@ -221,22 +224,25 @@ concurrent project shells.
221224

222225
`PostToolUse`, `PostToolUseFailure`, and `SessionStart` are registered with
223226
short timeouts (10s / 10s / 5s) in `hooks/hooks.json`. `PreToolUse` runs with
224-
a **75s** timeout because Task 8 (v0.5.0) wired the hook to block
225-
synchronously on the interactive terminal-overlay dialog.
227+
a **300s** timeout because the hook blocks synchronously on the interactive
228+
terminal-overlay dialog AND may also queue behind an overlay held by another
229+
CC session on the same machine.
226230

227-
The 75s figure breaks down as:
231+
The 300s figure breaks down as:
228232

229233
* The overlay dialog (`scripts/overlay-dialog.sh`) enforces its own 60s
230234
budget (`PASSTHRU_OVERLAY_TIMEOUT`, default 60s).
231-
* Add 15s of margin for overlay launch, multiplexer roundtrip, post-dialog
235+
* The overlay queue lock (`PASSTHRU_OVERLAY_LOCK_TIMEOUT`, default 180s)
236+
waits for other sessions' overlays to complete.
237+
* Add margin for overlay launch, multiplexer roundtrip, post-dialog
232238
rule write via `write-rule.sh`, and audit line emission.
233-
* CC's hook timeout is wall-clock (confirmed via `time sleep 1`: 1.008s
234-
real). Anything below the overlay's own budget would kill the hook
235-
mid-dialog and lose the user's verdict.
239+
* CC's hook timeout is wall-clock. Anything below the overlay's own budget
240+
plus the lock-wait budget would kill the hook mid-wait and lose the
241+
user's verdict.
236242

237243
The 10s baseline for non-overlay PreToolUse paths (rule match, mode
238244
auto-allow) still applies in the sense that none of them block on IO; the
239-
75s cap only matters when the overlay is actually invoked.
245+
300s cap only matters when the overlay is actually invoked.
240246

241247
For post-event handlers, the original 10s baseline continues to hold:
242248

@@ -256,6 +262,89 @@ Lower the PreToolUse timeout only after also lowering
256262
`PASSTHRU_OVERLAY_TIMEOUT` (and only after profiling on target hardware).
257263
Raising it is always safe since the handler fails open on timeout.
258264

265+
## Overlay queue lock (cross-session)
266+
267+
The overlay popup is singleton per machine: tmux/kitty/wezterm can only
268+
show one popup at a time. Two CC sessions racing for the overlay would
269+
otherwise both try to open popups and one would fail, falling through to
270+
CC's native dialog.
271+
272+
`hooks/handlers/pre-tool-use.sh` serializes overlays via a mkdir lock at
273+
`$(passthru_user_home)/passthru-overlay.lock.d`. The lock MUST live under
274+
user home, NOT `$TMPDIR`: on macOS `$TMPDIR` resolves to a per-process
275+
`/var/folders/<session-id>/.../T/` folder that is NOT shared across CC
276+
sessions of the same user. User home is the only guaranteed shared
277+
location.
278+
279+
Stale-lock recovery runs every ~2s during wait. If the existing lock's
280+
mtime is older than `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` (default 180s),
281+
the lock is force-removed. This prevents a hook that was SIGKILLed
282+
(OOM, manual kill) from blocking every subsequent overlay forever.
283+
284+
Env knobs:
285+
286+
* `PASSTHRU_OVERLAY_LOCK_TIMEOUT` (default 180s) - how long to wait for
287+
another session's overlay before falling back to CC's native dialog.
288+
* `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` (default 180s) - mtime age at which
289+
an existing lock is considered abandoned and auto-cleared.
290+
291+
## Interaction with CC's native permission system
292+
293+
Passthru is one of potentially several PreToolUse hooks AND sits alongside
294+
CC's built-in permission evaluation. Understanding which decision wins in
295+
which scenario is essential for debugging "why did the native dialog
296+
appear?" complaints.
297+
298+
**Decision cascade after PreToolUse hooks return:**
299+
300+
1. If any hook emits `permissionDecision: "allow"` - CC proceeds silently.
301+
2. If any hook emits `permissionDecision: "deny"` - CC blocks the tool.
302+
3. If a hook emits `permissionDecision: "ask"` - CC shows its NATIVE
303+
dialog. This is by design: "ask" explicitly defers to CC's UI.
304+
4. If all hooks pass through (`{"continue": true}`) - CC evaluates its own
305+
`permissions.allow` entries from `settings.json`. If none match, CC
306+
shows its native dialog.
307+
308+
Implication: passthru emitting `ask` (either explicitly or via overlay
309+
fall-through / lock timeout) will trigger a native dialog. Only `allow`
310+
fully suppresses it. This is why the compound readonly-filter fix (`go
311+
test | tail` now resolves to allow instead of ask) eliminates the native
312+
dialog cascade.
313+
314+
**Multi-plugin hook ordering:**
315+
316+
CC runs PreToolUse hooks in plugin registration order. Each subsequent
317+
hook sees `tool_input` as MODIFIED by previous hooks. Plugins like `rtk`
318+
(which rewrites `go test` to `rtk go test`) can either run before or
319+
after passthru depending on ordering:
320+
321+
* rtk BEFORE passthru: passthru sees `rtk go test ...`. User rule for
322+
`^go` does not match. Falls through to overlay.
323+
* rtk AFTER passthru: passthru sees `go test ...`. User rule matches,
324+
decision is "allow". CC then runs rtk which rewrites the command, CC
325+
executes the rewritten command.
326+
327+
If the user reports seeing the overlay for BOTH `go ...` and `rtk go ...`
328+
variants intermittently, hook ordering is non-deterministic or multiple
329+
rtk code paths (proxy vs rewrite) are in play. Rule coverage should
330+
anticipate both forms or use a broader pattern.
331+
332+
## Notifications on overlay prompt
333+
334+
`pre-tool-use.sh` sends an OSC 777 desktop notification before invoking
335+
the overlay so the user knows a prompt is waiting. Two gotchas:
336+
337+
* Must write to `/dev/tty`, NOT stdout. Stdout is captured by CC as the
338+
hook's JSON response and the OSC sequence would pollute (or invalidate)
339+
the JSON payload.
340+
* Inside tmux, the OSC must be wrapped in DCS passthrough: `ESC P tmux;
341+
<inner> ESC \` with every inner `ESC` doubled. Additionally tmux needs
342+
`set -g allow-passthrough on` in the user's tmux.conf. Without
343+
passthrough, tmux strips the OSC and Ghostty/iTerm2 never sees it.
344+
345+
OSC 777 format: `ESC ] 777 ; notify ; <title> ; <body> BEL`. Supported
346+
by Ghostty, iTerm2, Konsole, and most modern terminal emulators.
347+
259348
## Compound command splitting
260349

261350
For Bash tool calls, the hook splits compound commands into segments before

hooks/common.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,3 +2186,69 @@ match_all_segments() {
21862186

21872187
return 0
21882188
}
2189+
2190+
# ---------------------------------------------------------------------------
2191+
# compute_unallowed_segments
2192+
# ---------------------------------------------------------------------------
2193+
#
2194+
# Usage: compute_unallowed_segments <ordered_entries_json> <tool_name> \
2195+
# <cwd> <allowed_dirs_json> <segments...>
2196+
#
2197+
# For compound Bash commands that fall through to the overlay, identifies
2198+
# which segments are NOT covered by readonly auto-allow or by any allow/ask
2199+
# rule. The overlay uses this information to propose a rule targeting only
2200+
# the uncovered portion instead of the full command's first word.
2201+
#
2202+
# A segment is "unallowed" if:
2203+
# - It is NOT read-only auto-allowed (either not in readonly list or
2204+
# has absolute path outside cwd/allowed_dirs).
2205+
# - AND it does not match any allow or ask rule in ordered_entries.
2206+
#
2207+
# Output (stdout): NUL-separated list of unallowed segment strings.
2208+
# Return: 0 always.
2209+
compute_unallowed_segments() {
2210+
local ordered="$1"
2211+
local tool_name="$2"
2212+
local cwd="$3"
2213+
local allowed_dirs_json="$4"
2214+
shift 4
2215+
2216+
local _cus_segments=("$@")
2217+
local seg_count="${#_cus_segments[@]}"
2218+
[ "$seg_count" -eq 0 ] && return 0
2219+
2220+
local ordered_count
2221+
ordered_count="$(jq -r 'if type == "array" then length else 0 end' <<<"$ordered" 2>/dev/null)"
2222+
[ -z "$ordered_count" ] && ordered_count=0
2223+
2224+
local seg seg_idx seg_input entry rule mrc matched
2225+
for ((seg_idx = 0; seg_idx < seg_count; seg_idx++)); do
2226+
seg="${_cus_segments[$seg_idx]}"
2227+
2228+
# Skip read-only segments with valid paths.
2229+
if is_readonly_command "$seg" \
2230+
&& readonly_paths_allowed "$seg" "$cwd" "$allowed_dirs_json"; then
2231+
continue
2232+
fi
2233+
2234+
# Check if it matches any allow/ask rule.
2235+
seg_input="$(jq -cn --arg c "$seg" '{command: $c}')"
2236+
matched=0
2237+
local i
2238+
for ((i = 0; i < ordered_count; i++)); do
2239+
entry="$(jq -c --argjson i "$i" '.[$i]' <<<"$ordered" 2>/dev/null)"
2240+
rule="$(jq -c '.rule // {}' <<<"$entry" 2>/dev/null)"
2241+
mrc=0
2242+
match_rule "$tool_name" "$seg_input" "$rule" || mrc=$?
2243+
if [ "$mrc" -eq 0 ]; then
2244+
matched=1
2245+
break
2246+
fi
2247+
done
2248+
2249+
if [ "$matched" -eq 0 ]; then
2250+
printf '%s\0' "$seg"
2251+
fi
2252+
done
2253+
return 0
2254+
}

hooks/handlers/pre-tool-use.sh

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -538,10 +538,42 @@ ORDERED_COUNT="$(jq -r 'if type == "array" then length else 0 end' <<<"$ORDERED"
538538
MATCHED="" # "allow" | "ask" | ""
539539
if [ "$ORDERED_COUNT" -gt 0 ]; then
540540
if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then
541-
# Compound Bash command: use per-segment matching algorithm.
541+
# Compound Bash command: pre-filter readonly-auto-allowed segments
542+
# before per-segment matching. A segment is "covered" if it is either
543+
# readonly-auto-allowed OR matches an allow/ask rule. Filtering readonly
544+
# segments out lets match_all_segments make its all-or-nothing decision
545+
# on the remaining non-readonly segments.
546+
#
547+
# Example: `go test ./... | tail -50` with user rule `^go` and `tail`
548+
# in readonly list -> without filter, tail has no rule and command
549+
# falls through to overlay. With filter, tail is dropped, go matches
550+
# its rule, command is allowed.
551+
#
552+
# Guard: only filter when the ORIGINAL command has no output redirects.
553+
# If `cat file > /tmp/x` were filtered as readonly, we would mask the
554+
# write. has_redirect blocks this case by keeping the full segment list.
555+
_FILTERED_SEGMENTS=()
556+
if ! has_redirect "$BASH_CMD"; then
557+
for _seg in "${BASH_SEGMENTS[@]}"; do
558+
if is_readonly_command "$_seg" \
559+
&& readonly_paths_allowed "$_seg" "$CC_CWD" "$ALLOWED_DIRS_JSON"; then
560+
continue
561+
fi
562+
_FILTERED_SEGMENTS+=("$_seg")
563+
done
564+
else
565+
_FILTERED_SEGMENTS=("${BASH_SEGMENTS[@]}")
566+
fi
567+
568+
# If filtering left 0 segments, all were readonly and step 5b should
569+
# have handled it. Defensive fallback: use the original segments.
570+
if [ "${#_FILTERED_SEGMENTS[@]}" -eq 0 ]; then
571+
_FILTERED_SEGMENTS=("${BASH_SEGMENTS[@]}")
572+
fi
573+
542574
_MAS_RESULT=""
543575
_mas_rc=0
544-
_MAS_RESULT="$(match_all_segments "$ORDERED" "$TOOL_NAME" "${BASH_SEGMENTS[@]}" 2>/dev/null)" || _mas_rc=$?
576+
_MAS_RESULT="$(match_all_segments "$ORDERED" "$TOOL_NAME" "${_FILTERED_SEGMENTS[@]}" 2>/dev/null)" || _mas_rc=$?
545577
if [ "$_mas_rc" -eq 2 ]; then
546578
printf '[passthru] compound allow/ask rule regex error; passing through\n' >&2
547579
emit_passthrough
@@ -751,13 +783,41 @@ export PASSTHRU_OVERLAY_TOOL_NAME="$TOOL_NAME"
751783
export PASSTHRU_OVERLAY_TOOL_INPUT_JSON="$TOOL_INPUT"
752784
export PASSTHRU_OVERLAY_CWD="$CC_CWD"
753785

786+
# For compound Bash commands falling through to overlay, compute which
787+
# segments are uncovered (not readonly auto-allowed, not matched by any
788+
# allow/ask rule). The overlay uses this to propose a regex targeting only
789+
# the uncovered portion, not the full command's first word. Separator is
790+
# newline since env vars cannot carry NULs portably across multiplexer
791+
# popups. Segments never contain newlines because split_bash_command splits
792+
# at operators and redirections.
793+
PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS=""
794+
if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then
795+
_unallowed_raw="$(compute_unallowed_segments "$ORDERED" "$TOOL_NAME" "$CC_CWD" "$ALLOWED_DIRS_JSON" "${BASH_SEGMENTS[@]}" 2>/dev/null || true)"
796+
# Convert NUL separators to newlines for env var transport.
797+
if [ -n "$_unallowed_raw" ]; then
798+
PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="$(printf '%s' "$_unallowed_raw" | tr '\0' '\n')"
799+
fi
800+
fi
801+
export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS
802+
754803
# --- Overlay queue lock -------------------------------------------------------
755-
# CC can fire multiple PreToolUse hooks concurrently (parallel tool calls).
756-
# Only one overlay popup can be visible at a time in a given multiplexer.
757-
# Without serialization, the second+ hook falls through to CC's native dialog.
758-
# We use a mkdir-based lock to queue concurrent overlay invocations.
759-
_OVERLAY_LOCK="${_tmpdir}/passthru-overlay.lock.d"
760-
_OVERLAY_LOCK_TIMEOUT="${PASSTHRU_OVERLAY_LOCK_TIMEOUT:-90}"
804+
# CC can fire multiple PreToolUse hooks concurrently, and multiple CC sessions
805+
# on the same machine will also race for the same tmux/kitty/wezterm popup.
806+
# Only one overlay popup can be visible at a time. We serialize via a mkdir
807+
# lock at a user-scope path so the lock is shared across both intra-session
808+
# concurrent calls and cross-session concurrent calls.
809+
#
810+
# The lock MUST live under passthru_user_home, not TMPDIR, because macOS
811+
# gives each process a per-user (and sometimes per-session) TMPDIR under
812+
# /var/folders/... which is NOT shared across CC sessions.
813+
_OVERLAY_LOCK_ROOT="$(passthru_user_home)"
814+
[ -d "$_OVERLAY_LOCK_ROOT" ] || mkdir -p "$_OVERLAY_LOCK_ROOT" 2>/dev/null || true
815+
_OVERLAY_LOCK="${_OVERLAY_LOCK_ROOT}/passthru-overlay.lock.d"
816+
_OVERLAY_LOCK_TIMEOUT="${PASSTHRU_OVERLAY_LOCK_TIMEOUT:-180}"
817+
# If a lock is older than this, treat it as stale (process died without
818+
# releasing). Should be > PASSTHRU_OVERLAY_TIMEOUT (default 60s) plus
819+
# overlay launch + post-processing margin.
820+
_OVERLAY_LOCK_STALE_AFTER="${PASSTHRU_OVERLAY_LOCK_STALE_AFTER:-180}"
761821
_overlay_lock_acquired=0
762822

763823
_release_overlay_lock() {
@@ -767,8 +827,27 @@ _release_overlay_lock() {
767827
fi
768828
}
769829

770-
# Acquire the lock. Poll at 200ms intervals up to the timeout.
830+
# Detect and clear stale locks: if the lock directory exists but its mtime
831+
# is older than _OVERLAY_LOCK_STALE_AFTER seconds, a previous hook was
832+
# killed (SIGKILL, OOM, etc.) without running its EXIT trap. Force-clean.
833+
_clear_if_stale() {
834+
if [ -d "$_OVERLAY_LOCK" ]; then
835+
local mtime
836+
mtime="$(stat -f %m "$_OVERLAY_LOCK" 2>/dev/null || stat -c %Y "$_OVERLAY_LOCK" 2>/dev/null || echo 0)"
837+
local now age
838+
now="$(date +%s)"
839+
age=$((now - mtime))
840+
if [ "$age" -gt "$_OVERLAY_LOCK_STALE_AFTER" ]; then
841+
printf '[passthru] clearing stale overlay lock (age %ds)\n' "$age" >&2
842+
rm -rf "$_OVERLAY_LOCK" 2>/dev/null || true
843+
fi
844+
fi
845+
}
846+
847+
# Acquire the lock. Poll at 200ms intervals up to the timeout. Check for
848+
# stale locks every few iterations so a dead hook does not block forever.
771849
_lock_start="$(date +%s)"
850+
_stale_check_counter=0
772851
while true; do
773852
if mkdir "$_OVERLAY_LOCK" 2>/dev/null; then
774853
_overlay_lock_acquired=1
@@ -777,6 +856,12 @@ while true; do
777856
trap '_release_overlay_lock' EXIT
778857
break
779858
fi
859+
# Every 10th iteration (~2s), check for a stale lock.
860+
_stale_check_counter=$((_stale_check_counter + 1))
861+
if [ "$_stale_check_counter" -ge 10 ]; then
862+
_stale_check_counter=0
863+
_clear_if_stale
864+
fi
780865
_now="$(date +%s)"
781866
if [ $((_now - _lock_start)) -ge "$_OVERLAY_LOCK_TIMEOUT" ]; then
782867
printf '[passthru] overlay lock timeout after %ds; falling back to native dialog\n' "$_OVERLAY_LOCK_TIMEOUT" >&2
@@ -787,7 +872,19 @@ done
787872

788873
# Send a desktop notification so the user knows a permission prompt is waiting.
789874
# OSC 777 is supported by Ghostty, iTerm2, and other modern terminals.
790-
printf '\033]777;notify;passthru;permission prompt: %s\a' "$TOOL_NAME" 2>/dev/null || true
875+
# Write to /dev/tty, not stdout - stdout is captured by CC as the hook's JSON
876+
# response. When running inside tmux, the OSC sequence must be wrapped in
877+
# tmux's "passthrough" escape (DCS tmux; ... ST) to reach the outer terminal.
878+
_notify_msg="passthru: permission prompt: ${TOOL_NAME}"
879+
if [ -e /dev/tty ]; then
880+
if [ -n "${TMUX:-}" ]; then
881+
# tmux wraps: ESC P tmux; ESC <escaped-inner> ESC \
882+
# Inner ESC characters must be doubled (ESC ESC) for tmux passthrough.
883+
printf '\033Ptmux;\033\033]777;notify;passthru;%s\a\033\\' "$_notify_msg" > /dev/tty 2>/dev/null || true
884+
else
885+
printf '\033]777;notify;passthru;%s\a' "$_notify_msg" > /dev/tty 2>/dev/null || true
886+
fi
887+
fi
791888

792889
# Invoke the overlay and capture its exit code. We have an ERR trap in place
793890
# (converts unexpected errors to fail-open passthrough), so we cannot rely on

0 commit comments

Comments
 (0)