From bc9ad895a4723744c4c5af2331e5f53b2aff5e3f Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Mon, 13 Jul 2026 16:38:18 +0300 Subject: [PATCH 1/2] fix: never inject keystrokes while the human is typing (idle guard) Petrus 2026-07-13: 'tmux should not write if i am writing!' - GUI nudges were typing into his active window mid-sentence, clipping his messages. tools/human-idle-guard.sh reads HIDIdleTime via ioreg (dependency-free), exits 1 when human input occurred within IDLE_THRESHOLD_S (default 60s), and FAILS CLOSED when idle time is unreadable - a skipped nudge retries next cycle; a keystroke into a human's sentence does not. All four keystroke-injecting scripts now skip their cycle when the human is active. Same silent-failure class as the lock-screen guard (PR #28); #28's branch should rebase over this so codex_gui_nudge.sh carries both guards. Co-Authored-By: Claude Fable 5 --- scripts/claude-gui-wake.sh | 9 +++++++++ scripts/claudemb-wake.sh | 9 +++++++++ tools/codex_gui_nudge.sh | 9 +++++++++ tools/gemini_gui_nudge.sh | 9 +++++++++ tools/human-idle-guard.sh | 28 ++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+) create mode 100755 tools/human-idle-guard.sh diff --git a/scripts/claude-gui-wake.sh b/scripts/claude-gui-wake.sh index 9e54556..4a3db27 100755 --- a/scripts/claude-gui-wake.sh +++ b/scripts/claude-gui-wake.sh @@ -1,4 +1,13 @@ #!/usr/bin/env bash + +# Never type over the human (petrus 2026-07-13: "tmux should not write if i +# am writing!"). Skip this nudge cycle unless the machine is human-idle. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then + echo "skipping nudge: human recently active" >&2 + exit 0 +fi + # claude-gui-wake.sh — wake Claude Code desktop app via osascript # Requires: Accessibility permission for /Applications/Claude.app set -euo pipefail diff --git a/scripts/claudemb-wake.sh b/scripts/claudemb-wake.sh index 85d1463..f7030a0 100755 --- a/scripts/claudemb-wake.sh +++ b/scripts/claudemb-wake.sh @@ -1,4 +1,13 @@ #!/usr/bin/env bash + +# Never type over the human (petrus 2026-07-13: "tmux should not write if i +# am writing!"). Skip this nudge cycle unless the machine is human-idle. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then + echo "skipping nudge: human recently active" >&2 + exit 0 +fi + # ClaudeMB wake-up script # Uses osascript to send a nudge into the Claude Code desktop app # then restores focus to the previously active app (no focus steal). diff --git a/tools/codex_gui_nudge.sh b/tools/codex_gui_nudge.sh index 3298cd2..1c4f9f7 100755 --- a/tools/codex_gui_nudge.sh +++ b/tools/codex_gui_nudge.sh @@ -1,4 +1,13 @@ #!/usr/bin/env bash + +# Never type over the human (petrus 2026-07-13: "tmux should not write if i +# am writing!"). Skip this nudge cycle unless the machine is human-idle. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then + echo "skipping nudge: human recently active" >&2 + exit 0 +fi + set -euo pipefail APP_NAME="${IAK_CODEX_APP_NAME:-Codex}" diff --git a/tools/gemini_gui_nudge.sh b/tools/gemini_gui_nudge.sh index abd36c1..f119ee7 100755 --- a/tools/gemini_gui_nudge.sh +++ b/tools/gemini_gui_nudge.sh @@ -1,4 +1,13 @@ #!/usr/bin/env bash + +# Never type over the human (petrus 2026-07-13: "tmux should not write if i +# am writing!"). Skip this nudge cycle unless the machine is human-idle. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then + echo "skipping nudge: human recently active" >&2 + exit 0 +fi + set -euo pipefail APP_NAME="${IAK_GEMINI_APP_NAME:-Claude}" diff --git a/tools/human-idle-guard.sh b/tools/human-idle-guard.sh new file mode 100755 index 0000000..83be33b --- /dev/null +++ b/tools/human-idle-guard.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Refuse to inject keystrokes while the human is actively using the machine. +# Petrus 2026-07-13: "tmux should not write if i am writing!" - GUI nudges +# were typing into his active window mid-sentence. Same silent-failure class +# as the lock-screen guard (IAK PR #28). +# +# Usage (top of any keystroke-injecting script): +# "$(dirname "$0")/human-idle-guard.sh" || { echo "human active; skipping nudge" >&2; exit 0; } +# +# Exits 0 when the machine has been idle >= IDLE_THRESHOLD_S (default 60s), +# 1 when the human was active more recently (caller should SKIP the nudge), +# 0 with a warning if idle time cannot be determined... no: fail CLOSED - +# if we cannot tell, assume the human is active (exit 1). A skipped nudge +# retries on the next cycle; a keystroke into a human's sentence does not. +set -u +IDLE_THRESHOLD_S="${IDLE_THRESHOLD_S:-60}" + +idle_ns=$(ioreg -c IOHIDSystem 2>/dev/null | awk '/HIDIdleTime/ {print $NF; exit}') +if ! [[ "$idle_ns" =~ ^[0-9]+$ ]]; then + echo "human-idle-guard: cannot read HIDIdleTime; failing closed (assume active)" >&2 + exit 1 +fi +idle_s=$(( idle_ns / 1000000000 )) +if [ "$idle_s" -lt "$IDLE_THRESHOLD_S" ]; then + echo "human-idle-guard: human input ${idle_s}s ago (< ${IDLE_THRESHOLD_S}s); skip nudge" >&2 + exit 1 +fi +exit 0 From 83a246014c6d708b7f6ec9f8c3a127410199444d Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Mon, 13 Jul 2026 16:48:49 +0300 Subject: [PATCH 2/2] fix: address codex review on #29 - threshold fail-closed + wait-not-skip Blocker 2: malformed/zero IDLE_THRESHOLD_S no longer falls through to authorize injection - garbage clamps to the 60s default. Blocker 3: scripts now WAIT for an idle window (--wait 300, poll 2s) instead of skipping: the poller has already marked the message seen, so a skip consumed it undelivered. Timeout exits 1 into the caller's existing failure path. Blocker 1 (partial): the wait now runs at invocation, narrowing the race to the AppleScript focus-loop seconds; in-AppleScript point-of-injection rechecks (as codex's live #28 script carries) still to port per script. Co-Authored-By: Claude Fable 5 --- scripts/claude-gui-wake.sh | 9 ++++-- scripts/claudemb-wake.sh | 9 ++++-- tools/codex_gui_nudge.sh | 9 ++++-- tools/gemini_gui_nudge.sh | 9 ++++-- tools/human-idle-guard.sh | 56 +++++++++++++++++++++++++++++++------- 5 files changed, 70 insertions(+), 22 deletions(-) diff --git a/scripts/claude-gui-wake.sh b/scripts/claude-gui-wake.sh index 4a3db27..d2ab1ae 100755 --- a/scripts/claude-gui-wake.sh +++ b/scripts/claude-gui-wake.sh @@ -3,9 +3,12 @@ # Never type over the human (petrus 2026-07-13: "tmux should not write if i # am writing!"). Skip this nudge cycle unless the machine is human-idle. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then - echo "skipping nudge: human recently active" >&2 - exit 0 +# WAIT for an idle window rather than skipping: by the time this script runs +# the poller has marked the message seen, so skipping would consume it +# undelivered. Exit 1 on timeout so the caller's failure path applies. +if ! "$REPO_ROOT/tools/human-idle-guard.sh" --wait 300; then + echo "nudge aborted: human continuously active" >&2 + exit 1 fi # claude-gui-wake.sh — wake Claude Code desktop app via osascript diff --git a/scripts/claudemb-wake.sh b/scripts/claudemb-wake.sh index f7030a0..18c6c57 100755 --- a/scripts/claudemb-wake.sh +++ b/scripts/claudemb-wake.sh @@ -3,9 +3,12 @@ # Never type over the human (petrus 2026-07-13: "tmux should not write if i # am writing!"). Skip this nudge cycle unless the machine is human-idle. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then - echo "skipping nudge: human recently active" >&2 - exit 0 +# WAIT for an idle window rather than skipping: by the time this script runs +# the poller has marked the message seen, so skipping would consume it +# undelivered. Exit 1 on timeout so the caller's failure path applies. +if ! "$REPO_ROOT/tools/human-idle-guard.sh" --wait 300; then + echo "nudge aborted: human continuously active" >&2 + exit 1 fi # ClaudeMB wake-up script diff --git a/tools/codex_gui_nudge.sh b/tools/codex_gui_nudge.sh index 1c4f9f7..e00eff1 100755 --- a/tools/codex_gui_nudge.sh +++ b/tools/codex_gui_nudge.sh @@ -3,9 +3,12 @@ # Never type over the human (petrus 2026-07-13: "tmux should not write if i # am writing!"). Skip this nudge cycle unless the machine is human-idle. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then - echo "skipping nudge: human recently active" >&2 - exit 0 +# WAIT for an idle window rather than skipping: by the time this script runs +# the poller has marked the message seen, so skipping would consume it +# undelivered. Exit 1 on timeout so the caller's failure path applies. +if ! "$REPO_ROOT/tools/human-idle-guard.sh" --wait 300; then + echo "nudge aborted: human continuously active" >&2 + exit 1 fi set -euo pipefail diff --git a/tools/gemini_gui_nudge.sh b/tools/gemini_gui_nudge.sh index f119ee7..c047688 100755 --- a/tools/gemini_gui_nudge.sh +++ b/tools/gemini_gui_nudge.sh @@ -3,9 +3,12 @@ # Never type over the human (petrus 2026-07-13: "tmux should not write if i # am writing!"). Skip this nudge cycle unless the machine is human-idle. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if ! "$REPO_ROOT/tools/human-idle-guard.sh"; then - echo "skipping nudge: human recently active" >&2 - exit 0 +# WAIT for an idle window rather than skipping: by the time this script runs +# the poller has marked the message seen, so skipping would consume it +# undelivered. Exit 1 on timeout so the caller's failure path applies. +if ! "$REPO_ROOT/tools/human-idle-guard.sh" --wait 300; then + echo "nudge aborted: human continuously active" >&2 + exit 1 fi set -euo pipefail diff --git a/tools/human-idle-guard.sh b/tools/human-idle-guard.sh index 83be33b..f0bec59 100755 --- a/tools/human-idle-guard.sh +++ b/tools/human-idle-guard.sh @@ -14,15 +14,51 @@ # retries on the next cycle; a keystroke into a human's sentence does not. set -u IDLE_THRESHOLD_S="${IDLE_THRESHOLD_S:-60}" - -idle_ns=$(ioreg -c IOHIDSystem 2>/dev/null | awk '/HIDIdleTime/ {print $NF; exit}') -if ! [[ "$idle_ns" =~ ^[0-9]+$ ]]; then - echo "human-idle-guard: cannot read HIDIdleTime; failing closed (assume active)" >&2 - exit 1 +# A malformed or negative threshold must NOT authorize injection (codex +# review, #29: IDLE_THRESHOLD_S=not-a-number previously fell through to +# exit 0). Garbage -> the safe default. +if ! [[ "$IDLE_THRESHOLD_S" =~ ^[0-9]+$ ]] || [ "$IDLE_THRESHOLD_S" -eq 0 ]; then + echo "human-idle-guard: invalid IDLE_THRESHOLD_S='$IDLE_THRESHOLD_S'; using 60" >&2 + IDLE_THRESHOLD_S=60 fi -idle_s=$(( idle_ns / 1000000000 )) -if [ "$idle_s" -lt "$IDLE_THRESHOLD_S" ]; then - echo "human-idle-guard: human input ${idle_s}s ago (< ${IDLE_THRESHOLD_S}s); skip nudge" >&2 - exit 1 + +idle_seconds() { + local ns + ns=$(ioreg -c IOHIDSystem 2>/dev/null | awk '/HIDIdleTime/ {print $NF; exit}') + [[ "$ns" =~ ^[0-9]+$ ]] || return 1 + echo $(( ns / 1000000000 )) +} + +check_once() { + local s + if ! s=$(idle_seconds); then + echo "human-idle-guard: cannot read HIDIdleTime; failing closed (assume active)" >&2 + return 1 + fi + if [ "$s" -lt "$IDLE_THRESHOLD_S" ]; then + echo "human-idle-guard: human input ${s}s ago (< ${IDLE_THRESHOLD_S}s)" >&2 + return 1 + fi + return 0 +} + +# --wait [MAX_S]: poll until the idle window opens (default max 300s) so a +# nudge is DELAYED, never silently dropped - the poller has already marked +# the message seen by the time we run, so a skipped nudge would be a +# consumed-but-never-delivered message (codex review, #29). Timing out +# still exits 1 so the caller's existing failure path (retry/log) applies. +if [ "${1:-}" = "--wait" ]; then + MAX_WAIT_S="${2:-300}" + [[ "$MAX_WAIT_S" =~ ^[0-9]+$ ]] || MAX_WAIT_S=300 + waited=0 + while ! check_once; do + if [ "$waited" -ge "$MAX_WAIT_S" ]; then + echo "human-idle-guard: still active after ${MAX_WAIT_S}s; giving up" >&2 + exit 1 + fi + sleep 2; waited=$(( waited + 2 )) + done + exit 0 fi -exit 0 + +check_once