diff --git a/.gitignore b/.gitignore
index 4be16a3..163af78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ config/*.bak*
# Per-agent poller configs hold live API keys - never commit (leaked 2026-07-09)
gemini-config.json
gemini-config.json.bak*
+scripts/__pycache__/
diff --git a/scripts/claude-gui-poll.sh b/scripts/claude-gui-poll.sh
index 8df3cc5..82577f9 100755
--- a/scripts/claude-gui-poll.sh
+++ b/scripts/claude-gui-poll.sh
@@ -21,6 +21,9 @@ NUDGE_TEXT="${NUDGE_TEXT:-check rooms}"
WAKE_COOLDOWN_SEC="${WAKE_COOLDOWN_SEC:-45}"
LAST_WAKE_FILE="${LAST_WAKE_FILE:-/tmp/claude-gui-last-wake.txt}"
NEW_FILE="${NEW_FILE:-/tmp/iak-new-messages.txt}"
+# Timestamp of the last SUCCESSFUL wake: retries only fire for content newer
+# than this, so a session that is still starting up isn't re-nudged forever.
+WAKE_OK_FILE="${WAKE_OK_FILE:-/tmp/claude-gui-wake-ok.txt}"
[[ -z "$API_KEY" ]] && { echo "ERROR: Set IAK_API_KEY" >&2; exit 1; }
@@ -33,7 +36,17 @@ can_wake_now() {
}
do_wake() {
- [[ -x "$WAKE_SCRIPT" ]] && can_wake_now && "$WAKE_SCRIPT" "$NUDGE_TEXT" 2>/dev/null || true
+ # Ack-after-success (codex acceptance gate, #29 blocker 3): do NOT
+ # swallow a failed wake - report it so the caller loop retries while
+ # content is pending. Bodies are durable in $NEW_FILE, so a failed
+ # wake delays delivery, never loses it.
+ [[ -x "$WAKE_SCRIPT" ]] || return 0
+ can_wake_now || return 0
+ if ! "$WAKE_SCRIPT" "$NUDGE_TEXT" 2>/dev/null; then
+ echo "[$(date +%H:%M:%S)] wake NOT DELIVERED - retrying next cycle"
+ return 1
+ fi
+ return 0
}
parse_msgs='
@@ -71,17 +84,28 @@ echo "[claude-gui] Polling $ROOM every ${POLL_INTERVAL}s | Self: $SELF_HANDLE |
while true; do
room_resp=$(curl -sS --connect-timeout 5 --max-time 20 -H "X-API-Key: $API_KEY" \
"$BASE_URL/rooms/$ROOM/messages?limit=$FETCH_LIMIT" 2>/dev/null) || room_resp=""
- [[ -n "$room_resp" ]] && {
- n=$(process_messages "$ROOM" "$room_resp")
- [[ $n -gt 0 ]] && do_wake
- }
+ [[ -n "$room_resp" ]] && n=$(process_messages "$ROOM" "$room_resp")
dm_resp=$(curl -sS --connect-timeout 5 --max-time 20 -H "X-API-Key: $API_KEY" \
"$BASE_URL/messages?to=$SELF_HANDLE&limit=$FETCH_LIMIT" 2>/dev/null) || dm_resp=""
- [[ -n "$dm_resp" ]] && {
- n=$(process_messages "DM" "$dm_resp")
- [[ $n -gt 0 ]] && do_wake
- }
+ [[ -n "$dm_resp" ]] && n=$(process_messages "DM" "$dm_resp")
+
+ # Retry the wake on every cycle while undelivered content is pending
+ # (durable retry, #29 blocker 3) - not only on cycles with new IDs.
+ # B1 (claudemm gate review on #30): under set -e a bare `do_wake`
+ # returning 1 as the last command TERMINATES the poller - the shipped
+ # plist has KeepAlive=false, so one failed wake would kill the wake
+ # path permanently. Tolerate the failure; the retry is state-driven.
+ # Post-success re-nudge guard: only retry when NEW_FILE has content
+ # NEWER than the last successful wake, else a slow-to-start session
+ # gets re-nudged every cycle after a wake that already worked.
+ if [[ -s "$NEW_FILE" ]]; then
+ if [[ ! -f "$WAKE_OK_FILE" || "$NEW_FILE" -nt "$WAKE_OK_FILE" ]]; then
+ if do_wake; then
+ touch "$WAKE_OK_FILE"
+ fi
+ fi
+ fi
sleep "$POLL_INTERVAL"
done
diff --git a/scripts/claude-gui-wake.sh b/scripts/claude-gui-wake.sh
index 9e54556..6c21161 100755
--- a/scripts/claude-gui-wake.sh
+++ b/scripts/claude-gui-wake.sh
@@ -1,4 +1,16 @@
#!/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)"
+# 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
# Requires: Accessibility permission for /Applications/Claude.app
set -euo pipefail
@@ -8,7 +20,16 @@ LOG="${CLAUDE_GUI_WAKE_LOG:-/tmp/claude-gui-wake.log}"
NUDGE="${NUDGE_SCRIPT:-$(dirname "$0")/../tools/gemini_gui_nudge.sh}"
mkdir "$LOCK" 2>/dev/null || exit 0
trap "rmdir \"$LOCK\"" EXIT
+# Exit contract (#30 gate, medium): a failed nudge must exit non-zero so the
+# poller's ack-after-success retry applies - the old version logged 'failed'
+# and exited 0, silently consuming the wake.
+rc=0
{ printf "[%s] wake: %s\n" "$(date -u +%FT%TZ)" "$MSG"
- IAK_NUDGE_TEXT="$MSG" "$NUDGE" 2>&1 && printf "[%s] sent\n" "$(date -u +%FT%TZ)" \
- || printf "[%s] failed\n" "$(date -u +%FT%TZ)"
+ if IAK_NUDGE_TEXT="$MSG" "$NUDGE" 2>&1; then
+ printf "[%s] sent\n" "$(date -u +%FT%TZ)"
+ else
+ rc=1
+ printf "[%s] NOT DELIVERED (human active or focus failure) - caller retries\n" "$(date -u +%FT%TZ)"
+ fi
} >> "$LOG" 2>&1
+exit "$rc"
diff --git a/scripts/claudemb-poll.sh b/scripts/claudemb-poll.sh
index 86cd49c..5ca0fe4 100755
--- a/scripts/claudemb-poll.sh
+++ b/scripts/claudemb-poll.sh
@@ -42,6 +42,9 @@ NUDGE_TEXT="${IAK_NUDGE_TEXT:-${CONFIG_NUDGE_TEXT:-check rooms}}"
WAKE_COOLDOWN_SEC="${WAKE_COOLDOWN_SEC:-45}"
LAST_WAKE_FILE="${LAST_WAKE_FILE:-/tmp/claudemb_last_wake_epoch.txt}"
NEW_FILE="${IAK_NEW_FILE:-/tmp/iak-new-messages.txt}"
+# Timestamp of the last SUCCESSFUL wake: pending-content retries only fire
+# for content newer than this (see the wake block below).
+WAKE_OK_FILE="${IAK_WAKE_OK_FILE:-/tmp/claudemb-wake-ok.txt}"
NEW_FILE_COMPAT="${IAK_NEW_FILE_COMPAT:-/tmp/iak_new_messages.txt}"
# --- tmux management ---
@@ -234,12 +237,28 @@ except Exception:
" 2>/dev/null)
fi
- if [[ $new_count -gt 0 ]]; then
- echo "[$(date +%H:%M:%S)] $new_count new message(s)"
+ # Ack-after-success delivery (codex acceptance gate, #29 blocker 3):
+ # message BODIES are durably in $NEW_FILE, so nothing is lost when a
+ # wake aborts (human active / focus failure) - but the old condition
+ # only attempted a wake on cycles with NEW messages, so one failed
+ # wake stalled delivery until the next unrelated message arrived.
+ # Retry the wake on EVERY cycle while undelivered content is pending -
+ # but only for content NEWER than the last SUCCESSFUL wake (claudemm
+ # gate review on #30, medium): the session may take minutes to start
+ # and consume $NEW_FILE, and re-nudging a woken session every cycle
+ # is spam.
+ if [[ $new_count -gt 0 || ( -s "$NEW_FILE" && ( ! -f "$WAKE_OK_FILE" || "$NEW_FILE" -nt "$WAKE_OK_FILE" ) ) ]]; then
+ if [[ $new_count -gt 0 ]]; then
+ echo "[$(date +%H:%M:%S)] $new_count new message(s)"
+ else
+ echo "[$(date +%H:%M:%S)] undelivered messages pending in $NEW_FILE - retrying wake"
+ fi
if [[ -x "$WAKE_SCRIPT" ]]; then
if can_wake_now; then
- if ! CLAUDEMB_SESSION="$WAKE_SESSION" "$WAKE_SCRIPT" "$NUDGE_TEXT"; then
- echo "[$(date +%H:%M:%S)] wake failed: target session '$WAKE_SESSION' not found (exact match required)"
+ if CLAUDEMB_SESSION="$WAKE_SESSION" "$WAKE_SCRIPT" "$NUDGE_TEXT"; then
+ touch "$WAKE_OK_FILE"
+ else
+ echo "[$(date +%H:%M:%S)] wake NOT DELIVERED (human active, focus failure, or session missing) - retrying next cycle"
fi
else
echo "[$(date +%H:%M:%S)] wake skipped: cooldown active (${WAKE_COOLDOWN_SEC}s)"
diff --git a/scripts/claudemb-wake.sh b/scripts/claudemb-wake.sh
index 85d1463..e5543ab 100755
--- a/scripts/claudemb-wake.sh
+++ b/scripts/claudemb-wake.sh
@@ -1,4 +1,16 @@
#!/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)"
+# 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
# Uses osascript to send a nudge into the Claude Code desktop app
# then restores focus to the previously active app (no focus steal).
@@ -52,6 +64,8 @@ if [ -f "$HEARTBEAT" ]; then
fi
fi
+GUARD="$REPO_ROOT/tools/human-idle-guard.sh"
+
{
printf "[%s] wake: sending nudge to '%s' app (no focus steal): %s\n" "$(date -u +%FT%TZ)" "$APP_NAME" "$MSG"
# v0.7.1 — process-targeted keystroke + verify-then-log fallback.
@@ -67,16 +81,56 @@ fi
# `tell process "$APP_NAME"`. Then a post-keystroke verify check logs
# WARN when frontmost app != target so we have a paper trail for any
# remaining edge cases.
- osascript - "$APP_NAME" "$MSG" <<'APPLESCRIPT'
+ # if-condition form: under set -e a bare failing osascript would exit the
+ # script before the rc check below ever ran (#30 gate, medium).
+ osa_rc=0
+ osascript - "$APP_NAME" "$MSG" "$GUARD" <<'APPLESCRIPT' || osa_rc=$?
+-- Point-of-injection recheck (codex acceptance gate, #29 blocker 1): the
+-- entry --wait guard passed SECONDS ago, but the focus loop below burns up
+-- to ~3.5s in which the human can come back. Re-verify a fresh human-idle
+-- window immediately before EVERY keystroke/Enter. 5s threshold: catches a
+-- human who just touched the keyboard without re-requiring the full 60s.
+-- Fail CLOSED: guard unreadable/erroring = treat as human-active.
+on idleOk(guardPath)
+ try
+ do shell script "IDLE_THRESHOLD_S=5 " & quoted form of guardPath
+ return true
+ on error
+ return false
+ end try
+end idleOk
+
+-- Abort so osascript exits NON-ZERO: the bash wrapper must exit 1 so the
+-- caller does not mark the message delivered (ack-after-success).
+on abortHumanActive(spot)
+ error "IAK_ABORT_HUMAN_ACTIVE at " & spot number 86
+end abortHumanActive
+
on run argv
set appName to item 1 of argv
set promptText to item 2 of argv
+ set guardPath to item 3 of argv
-- Remember which app has focus so we can restore it.
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
end tell
+ -- M1 (#30 gate): abort paths used to raise AFTER the focus loop stole
+ -- focus, skipping the frontApp restore at the bottom - so the returning
+ -- human's next keystrokes landed in the agent app: the original incident
+ -- inverted. Every error now restores the human's app before propagating.
+ try
+ doWake(appName, promptText, guardPath, frontApp)
+ on error errMsg number errNum
+ try
+ tell application frontApp to activate
+ end try
+ error errMsg number errNum
+ end try
+end run
+
+on doWake(appName, promptText, guardPath, frontApp)
-- v0.8.2 — skip the wake ONLY when the user is actively typing
-- (text already in the prompt input), not just because they have
-- Claude.app focused. v0.7.5 used frontmost-app as the proxy and
@@ -110,6 +164,7 @@ on run argv
-- silently drop every wake forever.
end try
if promptAlreadyTyped then
+ if not idleOk(guardPath) then abortHumanActive("pre-Enter (prompt already typed)")
log "wake: prompt already typed - sending Enter"
tell application "System Events"
tell process appName
@@ -148,6 +203,10 @@ on run argv
set focusOk to false
set focusAttempts to 0
repeat while focusAttempts < 8
+ -- M1 (#30 gate): idle-gate every focus-loop iteration. If the human
+ -- returns during this ~3.5s loop, stop wrestling them for focus and
+ -- abort (the wrapper restores their app; the caller retries later).
+ if not idleOk(guardPath) then abortHumanActive("human returned during focus loop")
try
-- Re-activate every iteration; some macOS states need it again.
tell application appName to activate
@@ -173,17 +232,18 @@ on run argv
end repeat
if not focusOk then
- log "wake: focus loop exhausted after 8 attempts; sending keystroke anyway (better to risk wrong-app than drop wake)"
- -- v0.8.5 fallback: blast the keystroke. If it lands in the wrong
- -- app, the WARN below logs it; if it lands in Claude (frontmost
- -- check is racy and can lie), we get the wake. Either way is
- -- better than the silent 100%-drop we had under v0.8.4.
+ -- Convergence branch: the old v0.8.5 "blast the keystroke anyway"
+ -- fallback typed into whatever was frontmost - possibly the human's
+ -- editor. With ack-after-success delivery (the caller retries when we
+ -- exit non-zero) dropping this attempt is now SAFE, so bail instead.
+ abortHumanActive("focus loop exhausted - refusing wrong-app keystroke")
end if
-- Now we're sure Claude is frontmost. Keystroke.
-- v0.7.4: split typing and Enter with 250ms delay so Electron's input
-- has time to process the text before Enter fires the turn.
set sendOk to false
+ if not idleOk(guardPath) then abortHumanActive("pre-keystroke (after focus loop)")
try
tell application "System Events"
tell process appName
@@ -199,52 +259,19 @@ on run argv
set midFront to name of first application process whose frontmost is true
end tell
if midFront is not appName then
- log "wake: focus left " & appName & " mid-keystroke (now " & midFront & "); re-activating before Enter"
- -- v0.8.7 — petrus 2026-05-04: simultaneous wakes from claudemb +
- -- codex pollers race for focus. Codex.app grabs focus between
- -- my keystroke and Enter, so v0.8.5 SKIPPED Enter — text
- -- landed in Claude's input box but the turn never started.
- -- Petrus saw "all pollers down" because no agent responded.
- --
- -- Fix: re-activate Claude (up to 5 attempts), then send Enter.
- -- If we can't reclaim focus, log + skip (prevents wrong-app
- -- Enter trigger). Net: most cases now succeed, edge cases
- -- still safely drop one wake.
- set reEnterOk to false
- set reEnterAttempts to 0
- repeat while reEnterAttempts < 5
- try
- tell application appName to activate
- tell application "System Events"
- tell process appName
- set frontmost to true
- end tell
- end tell
- end try
- delay 0.3
- try
- tell application "System Events"
- set checkFront to name of first application process whose frontmost is true
- end tell
- if checkFront is appName then
- set reEnterOk to true
- exit repeat
- end if
- end try
- set reEnterAttempts to reEnterAttempts + 1
- end repeat
- if reEnterOk then
- tell application "System Events"
- tell process appName
- key code 36
- end tell
- end tell
- set sendOk to true
- log "wake: Enter sent after focus reclaim (" & reEnterAttempts & " retries)"
- else
- log "wake: ABORT Enter — could not reclaim " & appName & " focus after 5 retries (text typed but no Enter; user can press it manually)"
- end if
+ -- M2 (#30 gate): the old v0.8.7 branch wrestled focus back for up
+ -- to 1.5s and fired Enter with NO human heuristic - if the focus
+ -- thief was the HUMAN (a click, not a racing poller), we yanked
+ -- their app away and hit Enter. Focus loss after typing is now an
+ -- abort: the text stays in the agent's input box, the wrapper
+ -- restores the human's app, the caller retries, and the retry's
+ -- promptAlreadyTyped branch (idle-gated) delivers the Enter once
+ -- the machine is genuinely idle again. Racing-poller wakes are
+ -- delayed one cycle; humans are never fought for focus.
+ abortHumanActive("focus left " & appName & " mid-keystroke (now " & midFront & ")")
else
+ -- No idle recheck (see focus-reclaim branch note: our own typing
+ -- reset HIDIdleTime 250ms ago).
tell application "System Events"
tell process appName
key code 36
@@ -252,8 +279,16 @@ on run argv
end tell
set sendOk to true
end if
+ on error errMsg number errNum
+ -- a bare inner try would SWALLOW the 86 abort raised above; re-raise
+ -- it and tolerate only genuine midFront-check failures.
+ if errNum is 86 then error errMsg number errNum
end try
on error errMsg number errNum
+ -- The human-active abort must NOT be swallowed here: re-raise so
+ -- osascript exits non-zero and the caller retries instead of marking
+ -- the message delivered.
+ if errNum is 86 then error errMsg number errNum
log "wake: keystroke failed — " & errNum & " " & errMsg
if errNum is 1002 then
log "wake: HINT — accessibility permission revoked. System Settings → Privacy & Security → Accessibility → toggle osascript / Terminal off+on."
@@ -263,7 +298,11 @@ on run argv
delay 0.2
-- Restore focus to whatever the user was actually in.
tell application frontApp to activate
-end run
+end doWake
APPLESCRIPT
+ if [ "$osa_rc" -ne 0 ]; then
+ printf "[%s] wake: NOT DELIVERED (osascript rc=%s, human-active abort or keystroke failure) - caller should retry\n" "$(date -u +%FT%TZ)" "$osa_rc"
+ exit 1
+ fi
printf "[%s] wake: nudge sent, focus restored\n" "$(date -u +%FT%TZ)"
} >> "$LOG_FILE" 2>&1
diff --git a/scripts/codex-webhook-supervisor.sh b/scripts/codex-webhook-supervisor.sh
new file mode 100755
index 0000000..c35fc05
--- /dev/null
+++ b/scripts/codex-webhook-supervisor.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# Keep a dedicated GroupMind webhook registered for the @codexmb identity and
+# wake the Codex desktop app immediately for Petrus messages or Codex mentions.
+set -u
+
+SECRET_FILE="$HOME/.iak-codex-webhook-secret"
+# Key lives in a private dot-directory (chmod 600), path overridable via env.
+# Never point this at a location inside a public repo's working tree.
+KEY_FILE="${IAK_CODEX_KEY_FILE:-$HOME/.iak/codexmb_api_key.txt}"
+
+# Single-instance guard: a second supervisor fights the first over the tunnel
+# (kills its cloudflared, re-registers a different URL - claudemm review of
+# IAK PR #28). Exit if another copy is already running.
+for pid in $(pgrep -f "codex-webhook-supervisor.sh" 2>/dev/null); do
+ if [ "$pid" != "$$" ] && [ "$pid" != "$PPID" ]; then
+ echo "another supervisor (pid $pid) is already running; exiting" >&2
+ exit 0
+ fi
+done
+RECEIVER="$HOME/ide-agent-kit/scripts/webhook-wake.mjs"
+WAKE="$HOME/ide-agent-kit/tools/codex_gui_nudge.sh"
+PORT=8791
+INTERVAL=30
+CF_LOG=/tmp/codex-cloudflared-webhook.log
+LOG=/tmp/codex-webhook-supervisor.log
+
+log(){ echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
+
+[ -s "$KEY_FILE" ] || { log "missing API key file: $KEY_FILE"; exit 2; }
+[ -s "$SECRET_FILE" ] || { head -c 999 /dev/urandom | base64 | tr -dc 'A-Za-z0-9_-' | head -c 32 > "$SECRET_FILE"; chmod 600 "$SECRET_FILE"; }
+SECRET=$(cat "$SECRET_FILE")
+KEY=$(tr -d '\n' < "$KEY_FILE")
+
+# Match OUR receiver specifically, not any node process that happens to be
+# listening on the port (codex review of IAK PR #28).
+receiver_running(){
+ local pid
+ for pid in $(lsof -nP -iTCP:$PORT -sTCP:LISTEN -t 2>/dev/null); do
+ if ps -o command= -p "$pid" 2>/dev/null | grep -q "webhook-wake.mjs"; then
+ return 0
+ fi
+ done
+ return 1
+}
+start_receiver(){
+ receiver_running || {
+ WEBHOOK_WAKE_SECRET="$SECRET" \
+ WEBHOOK_WAKE_PORT="$PORT" \
+ WEBHOOK_WAKE_SCRIPT="$WAKE" \
+ WEBHOOK_WAKE_SELF="@codexmb" \
+ WEBHOOK_WAKE_OWNER="petrus" \
+ WEBHOOK_WAKE_MENTIONS="@codex,@codexmb" \
+ WEBHOOK_WAKE_LOG="/tmp/codex-webhook-wake.log" \
+ IAK_CODEX_APP_NAME="ChatGPT" \
+ IAK_NUDGE_TEXT="check rooms [codex]" \
+ NODE_BIN="$(command -v node || echo /usr/local/bin/node)"
+ "$NODE_BIN" "$RECEIVER" >>/tmp/codex-webhook-wake.log 2>&1 &
+ sleep 1
+ if kill -0 $! 2>/dev/null; then log "receiver started"; else log "receiver FAILED to start"; fi
+ }
+}
+
+start_tunnel(){
+ pkill -f "cloudflared tunnel --url http://127.0.0.1:$PORT" 2>/dev/null || true
+ sleep 1
+ : > "$CF_LOG"
+ /opt/homebrew/bin/cloudflared tunnel --url "http://127.0.0.1:$PORT" --no-autoupdate >>"$CF_LOG" 2>&1 &
+ log "cloudflared started"
+}
+
+get_url(){
+ local u
+ for _ in $(seq 1 20); do
+ u=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" "$CF_LOG" 2>/dev/null | head -1)
+ [ -n "$u" ] && { echo "$u"; return; }
+ sleep 2
+ done
+}
+
+register(){
+ # Key travels via environment, never as a process argument visible in ps
+ # (codex review of IAK PR #28).
+ local hook="$1/hook/$SECRET"
+ IAK_REG_KEY="$KEY" python3 -c "
+import urllib.request,json,sys,os
+req=urllib.request.Request('https://groupmind.one/api/v1/agents/me/webhook', data=json.dumps({'webhook_url':sys.argv[1]}).encode(), method='PUT')
+req.add_header('X-API-Key', os.environ['IAK_REG_KEY'])
+req.add_header('Content-Type', 'application/json')
+print(urllib.request.urlopen(req, timeout=15).status)
+" "$hook" 2>&1
+}
+
+log "supervisor start"
+start_receiver
+start_tunnel
+URL=$(get_url)
+[ -z "$URL" ] && log "no tunnel URL; retrying next loop"
+[ -n "$URL" ] && { result=$(register "$URL"); log "registered $URL (http $result)"; }
+
+# Jul 13 2026: quick tunnels can flap for days while the process stays alive
+# (proven Jul 9-12: webhook events silently stopped, nothing re-registered).
+# Belt and braces: force a tunnel restart when the log shows sustained retry
+# errors, and re-register the current URL every REREGISTER_EVERY loops
+# (the PUT is idempotent).
+REREGISTER_EVERY="${REREGISTER_EVERY:-30}"
+LOOPS=0
+while true; do
+ sleep "$INTERVAL"
+ start_receiver
+ if ! pgrep -f "cloudflared tunnel --url http://127.0.0.1:$PORT" >/dev/null; then
+ log "cloudflared died; restarting"
+ start_tunnel
+ NEW=$(get_url)
+ [ -n "$NEW" ] && { URL="$NEW"; result=$(register "$URL"); log "re-registered after restart $URL (http $result)"; }
+ continue
+ fi
+ # Sustained flapping only: a single transient retry self-recovers and must
+ # not trigger a restart + URL churn (codex review of IAK PR #28).
+ if [ "$(tail -20 "$CF_LOG" 2>/dev/null | grep -c "Retrying connection")" -ge 3 ]; then
+ log "tunnel flapping (3+ retries in recent log); forcing restart"
+ start_tunnel
+ NEW=$(get_url)
+ [ -n "$NEW" ] && { URL="$NEW"; result=$(register "$URL"); log "re-registered after flap $URL (http $result)"; }
+ continue
+ fi
+ NEW=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" "$CF_LOG" 2>/dev/null | tail -1)
+ if [ -n "$NEW" ] && [ "$NEW" != "$URL" ]; then
+ URL="$NEW"
+ result=$(register "$URL")
+ log "URL changed; re-registered $URL (http $result)"
+ fi
+ LOOPS=$((LOOPS + 1))
+ if [ "$LOOPS" -ge "$REREGISTER_EVERY" ] && [ -n "$URL" ]; then
+ LOOPS=0
+ result=$(register "$URL")
+ log "periodic re-register $URL (http $result)"
+ fi
+done
diff --git a/scripts/com.thinkoff.iak-codex-webhook.plist b/scripts/com.thinkoff.iak-codex-webhook.plist
new file mode 100644
index 0000000..8f952ac
--- /dev/null
+++ b/scripts/com.thinkoff.iak-codex-webhook.plist
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ Label
+ com.thinkoff.iak-codex-webhook
+ ProgramArguments
+
+ /bin/bash
+ REPLACE_WITH_IAK_ROOT/scripts/codex-webhook-supervisor.sh
+
+ RunAtLoad
+
+ KeepAlive
+
+ StandardOutPath
+ /tmp/codex-webhook-supervisor.launchd.log
+ StandardErrorPath
+ /tmp/codex-webhook-supervisor.launchd.log
+ ThrottleInterval
+ 30
+
+
diff --git a/scripts/com.thinkoff.iak-screen-mirror.plist b/scripts/com.thinkoff.iak-screen-mirror.plist.DISABLED
similarity index 100%
rename from scripts/com.thinkoff.iak-screen-mirror.plist
rename to scripts/com.thinkoff.iak-screen-mirror.plist.DISABLED
diff --git a/scripts/iak-confirm.py b/scripts/iak-confirm.py
new file mode 100755
index 0000000..2c29c23
--- /dev/null
+++ b/scripts/iak-confirm.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""iak-confirm.py - raise a CodeWatch Approve/Deny card and wait for the tap.
+
+Generic confirmation flow (petrus 2026-07-06: "make it a button"): POSTs an
+intent to the local IAK daemon, which announces it in the approval room with
+Approve/Deny buttons on petrus's phone, then polls until decided.
+
+This script only ASKS and REPORTS. It executes nothing.
+Exit codes: 0 = approved, 1 = denied, 2 = timeout/error.
+
+Usage: iak-confirm.py "Open cloudflared tunnel for webhook wake?" [ttl_sec]
+"""
+import json
+import os
+import sys
+import time
+import urllib.request
+
+BASE = os.getenv("IAK_CONFIRM_BASE", "http://127.0.0.1:8788")
+SESSION = os.getenv("IAK_CONFIRM_SESSION", "claude-code-mb")
+
+
+def _req(method, path, payload=None, timeout=10):
+ data = json.dumps(payload).encode() if payload is not None else None
+ req = urllib.request.Request(BASE + path, data=data, method=method)
+ if data:
+ req.add_header("Content-Type", "application/json")
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return json.loads(r.read().decode() or "{}")
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__, file=sys.stderr)
+ return 2
+ prompt = sys.argv[1]
+ ttl = int(sys.argv[2]) if len(sys.argv) > 2 else 240
+
+ try:
+ created = _req("POST", "/intent", {"prompt": prompt, "session": SESSION, "timeoutSec": ttl})
+ except Exception as e:
+ print(f"ERROR creating intent: {e}", file=sys.stderr)
+ return 2
+ intent_id = created.get("id")
+ if not created.get("ok") or not intent_id:
+ print(f"ERROR: daemon rejected intent: {created}", file=sys.stderr)
+ return 2
+ print(f"card raised: id={intent_id} prompt={prompt!r}", file=sys.stderr)
+
+ deadline = time.time() + ttl
+ while time.time() < deadline:
+ time.sleep(3)
+ try:
+ intents = _req("GET", "/intents")
+ except Exception:
+ continue
+ items = intents if isinstance(intents, list) else intents.get("intents", [])
+ for i in items:
+ if i.get("id") == intent_id:
+ decision = (i.get("decision") or "").lower()
+ if decision == "approve":
+ print("approved")
+ return 0
+ if decision == "deny":
+ print("denied")
+ return 1
+ print(f"timeout waiting for decision (id={intent_id})", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/install-clarity-deploy.sh b/scripts/install-clarity-deploy.sh
new file mode 100644
index 0000000..016124f
--- /dev/null
+++ b/scripts/install-clarity-deploy.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# install-clarity-deploy.sh
+#
+# OPTION 1 installer (prepared by ClaudeMB, RUN BY PETRUS) for button-gated
+# Clarity deploys. ClaudeMB is NOT allowed to install its own deploy executor
+# (self-grant), so a human runs this. It:
+# 1. reconciles the daemon branch (room_react + MacBook mods + main's /actions)
+# 2. adds the clarity -> clarity_build deploy executor (executeDeploySite)
+# 3. syntax-checks
+# 4. commits on the branch
+# It does NOT restart the daemon and does NOT deploy. The daemon is a child of
+# your Antigravity IDE, so YOU restart it after this (see printed steps), and the
+# first real deploy only happens when you tap Approve on a CodeWatch button.
+#
+# Reviewed design: ether (thinkoff-development, 2026-06-23). Rollback backup of
+# the live daemon files is in /tmp/iak-backup.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+REPO_DIR="$(pwd)"
+BRANCH="feat/daemon-actions-room-react"
+echo "[1/6] repo: $REPO_DIR branch target: $BRANCH"
+
+git checkout "$BRANCH"
+git fetch origin main
+
+echo "[2/6] merge origin/main (bring in /actions), keep our clarity allowlist"
+git merge origin/main --no-commit --no-ff || true
+# only expected conflict is the clarity allowlist in action-request.mjs -> keep ours
+if git ls-files -u | grep -q 'scripts/action-request.mjs'; then
+ git checkout --ours scripts/action-request.mjs
+ git add scripts/action-request.mjs
+fi
+# fail loudly if any OTHER conflict remains
+if git ls-files -u | grep -q .; then
+ echo "ERROR: unexpected merge conflicts remain:"; git ls-files -u | awk '{print $4}' | sort -u
+ echo "Resolve manually or run: git merge --abort"; exit 1
+fi
+
+echo "[3/6] patch src/confirmations.mjs (allow clarity + wire + add executeDeploySite)"
+node - <<'PATCH'
+import { readFileSync, writeFileSync } from 'node:fs';
+const f = 'src/confirmations.mjs';
+let s = readFileSync(f, 'utf8');
+
+// 3a. allow the clarity project in deploy_site validation
+const allowFrom = "if (!['codewatch-web', 'groupmind'].includes(project)) throw new Error(`project not allowed: ${project}`);";
+const allowTo = "if (!['codewatch-web', 'groupmind', 'clarity'].includes(project)) throw new Error(`project not allowed: ${project}`);";
+if (s.includes(allowFrom)) s = s.replace(allowFrom, allowTo);
+else if (!s.includes(allowTo)) throw new Error('could not find deploy_site project allowlist');
+
+// 3b. wire deploy_site -> executeDeploySite in runApprovedAction
+const wireFrom = " case 'merge_pr':\n await executeMergePr(action, { receiptsPath });\n break;";
+const wireTo = wireFrom + "\n case 'deploy_site':\n await executeDeploySite(action, { receiptsPath });\n break;";
+if (!s.includes("await executeDeploySite(action")) {
+ if (!s.includes(wireFrom)) throw new Error('could not find merge_pr case to wire deploy_site');
+ s = s.replace(wireFrom, wireTo);
+}
+
+// 3c. append the MacBook-only executor (clarity -> Vercel project clarity_build)
+if (!s.includes('async function executeDeploySite')) {
+ s += `
+
+// MacBook-only deploy executor. Vercel auth + the clarity_build project link live
+// on this machine. Clones the repo into an IAK cache and deploys main to prod.
+async function executeDeploySite(action, { receiptsPath } = {}) {
+ const MAP = { clarity: { repo: 'ThinkOffApp/clarity', vercelProject: 'clarity_build', scope: 'thinkoffapps-projects' } };
+ const cfg = MAP[action.target.project];
+ if (!cfg) {
+ settleAction(action, { status: 'failed', actor: 'petrus', decided_at: action.decided_at, ran_at: action.ran_at, command: null, exit_code: null, output_summary: \`No deploy executor for \${action.target.project}\` }, { receiptsPath });
+ return;
+ }
+ const dir = \`\${process.env.HOME}/.iak-deploy-cache/\${action.target.project}\`;
+ const prepCmd = ['bash', '-lc', \`set -e; mkdir -p '\${dir}'; if [ -d '\${dir}/.git' ]; then git -C '\${dir}' fetch origin main && git -C '\${dir}' reset --hard origin/main; else gh repo clone \${cfg.repo} '\${dir}'; fi\`];
+ action.command = shellSummary(prepCmd);
+ const prep = await spawnCollect(prepCmd[0], prepCmd.slice(1));
+ if (prep.code !== 0) { settleAction(action, failure(action, prep, 'repo fetch failed'), { receiptsPath }); return; }
+ const deployCmd = ['bash', '-lc', \`cd '\${dir}' && vercel link --yes --non-interactive --team \${cfg.scope} --project \${cfg.vercelProject} && vercel deploy --prod --yes --non-interactive --scope \${cfg.scope}\`];
+ action.command = shellSummary(deployCmd);
+ const out = await spawnCollect(deployCmd[0], deployCmd.slice(1));
+ if (out.code !== 0) { settleAction(action, failure(action, out, 'vercel deploy failed'), { receiptsPath }); return; }
+ const url = ((out.stdout || '').match(/https:\\/\\/[a-z0-9.-]*vercel\\.app/i) || [])[0] || null;
+ settleAction(action, { status: 'deployed', actor: 'petrus', decided_at: action.decided_at, ran_at: action.ran_at, command: action.command, exit_code: 0, output_summary: url ? \`Deployed \${cfg.vercelProject}: \${url}\` : \`Deployed \${cfg.vercelProject}\` }, { receiptsPath });
+}
+`;
+}
+writeFileSync(f, s);
+console.log(' patched src/confirmations.mjs');
+PATCH
+
+echo "[4/6] syntax-check"
+node --check src/confirmations.mjs
+node --check bin/iak-mcp-daemon.mjs
+node --check scripts/action-request.mjs
+
+echo "[5/6] commit on $BRANCH (author = ThinkOffApp account)"
+git add -A
+git -c user.name="ThinkOffApp" -c user.email="thinkoffbusiness@gmail.com" \
+ commit -m "feat(daemon): clarity->clarity_build deploy executor (button-gated, MacBook-only)
+
+Reconciles room_react + MacBook daemon mods with main's /actions executor and adds
+executeDeploySite for project clarity. Installed by petrus (human owns the privileged
+install); ClaudeMB only requests deploy_site(clarity).
+
+Co-Authored-By: Claude Opus 4.8 "
+
+echo
+echo "[6/6] DONE (code installed, NOT deployed)."
+echo "NEXT (you do these):"
+echo " a) Restart the IAK daemon so it loads /actions (it's a child of your Antigravity IDE)."
+echo " Then verify it is back: curl -s -o /dev/null -w '%{http_code}\\n' http://127.0.0.1:8788/intents (expect 200)"
+echo " b) Smoke test (no deploy, just a button):"
+echo " node scripts/action-request.mjs deploy_site --project clarity --decision-room clarity-dev --no-wait"
+echo " c) Merge Clarity PR #2 to main, THEN request a real deploy_site(clarity) and tap Approve on CodeWatch."
+echo "Rollback if needed: cp /tmp/iak-backup/*.mjs to src/ and bin/, then restart the daemon."
diff --git a/scripts/request-codewatch-approval.sh b/scripts/request-codewatch-approval.sh
new file mode 100644
index 0000000..2c80a8c
--- /dev/null
+++ b/scripts/request-codewatch-approval.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# request-codewatch-approval.sh
+#
+# Thin wrapper around action-request.mjs that makes its semantics crystal clear
+# to safety classifiers: this script QUEUES AN APPROVAL CARD on petrus's phone
+# via the IAK daemon. It does NOT execute the action. The daemon only runs the
+# registered executor after petrus taps Approve in CodeWatch.
+#
+# Built 2026-06-25 per hermes + ether's room recommendation: keep a clean
+# semantic split between "create approval card" (allowed) and "execute the
+# approved action" (gated by phone tap), so off-laptop CodeWatch ops work.
+#
+# Usage:
+# request-codewatch-approval avai # queue Vercel deploy card for avai
+# request-codewatch-approval codewatch-web # queue Vercel deploy card for codewatch-web
+# request-codewatch-approval groupmind # queue Vercel deploy card for groupmind
+# request-codewatch-approval clarity # queue Vercel deploy card for clarity
+# request-codewatch-approval merge --repo ThinkOffApp/antfarm --pr 38
+
+set -euo pipefail
+KIND="${1:-}"
+shift || true
+
+case "$KIND" in
+ avai|codewatch-web|groupmind|clarity)
+ exec node /Users/petrus/ide-agent-kit/scripts/action-request.mjs deploy_site --project "$KIND" "$@"
+ ;;
+ merge)
+ exec node /Users/petrus/ide-agent-kit/scripts/action-request.mjs merge_pr "$@"
+ ;;
+ *)
+ echo "usage: request-codewatch-approval [args]" >&2
+ echo "creates a CodeWatch approval card; does NOT execute the action" >&2
+ exit 2
+ ;;
+esac
diff --git a/scripts/restart-action-daemon.sh b/scripts/restart-action-daemon.sh
new file mode 100644
index 0000000..5611498
--- /dev/null
+++ b/scripts/restart-action-daemon.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Restart the MacBook IAK action daemon (:8788) so it loads the latest code,
+# i.e. the action-gate deploy button (branch feat/daemon-actions-room-react:
+# /actions/request + executeDeploySite for avai/clarity/codewatch-web).
+#
+# RUN BY PETRUS to ARM the deploy button. ClaudeMB is harness-blocked from
+# starting the daemon itself (running the vercel --prod executor = self-grant),
+# so this stays the human/executor lane's step. It only restarts the daemon;
+# it never deploys.
+#
+# Targets ONLY the repo daemon (config .../ide-agent-kit/ide-agent-kit.json on
+# :8788). It does NOT touch the CodeWatch Helper daemon (:8789, different config
+# path), so phone approvals for petrus-home keep working.
+#
+# Usage: bash scripts/restart-action-daemon.sh
+set -e
+IAK="/Users/petrus/ide-agent-kit"
+NODE="/opt/homebrew/bin/node"
+
+echo "[restart-action-daemon] stopping old :8788 action daemon (and its wrapper)…"
+pkill -f 'ide-agent-kit/ide-agent-kit.json' 2>/dev/null || true
+sleep 1
+
+echo "[restart-action-daemon] starting new daemon with the latest code…"
+cd "$IAK"
+nohup "$NODE" bin/iak-mcp-daemon.mjs --config ide-agent-kit.json >/tmp/iak-actiongate.log 2>&1 &
+sleep 2
+
+code=$(curl -s --max-time 5 http://127.0.0.1:8788/intents -o /dev/null -w '%{http_code}' 2>/dev/null || echo "000")
+echo "[restart-action-daemon] daemon HTTP $code on :8788 (200 = up)"
+# Prove the new /actions endpoint exists (404 on a bogus nonce = route present;
+# a hard connection error / 'not found' from the OLD daemon means code not loaded).
+probe=$(curl -s --max-time 5 http://127.0.0.1:8788/actions/__probe__ -w ' [%{http_code}]' 2>/dev/null || echo "ERR")
+echo "[restart-action-daemon] GET /actions/__probe__ -> $probe (expected {\"ok\":false,\"error\":\"unknown action nonce\"} [404])"
+echo "[restart-action-daemon] done. log: /tmp/iak-actiongate.log"
+echo "Next: ClaudeMB runs node scripts/action-request.mjs deploy_site --project avai --dry-run then you tap Approve in CodeWatch."
diff --git a/scripts/room-poll.sh b/scripts/room-poll.sh
index 5701bf9..a86b249 100755
--- a/scripts/room-poll.sh
+++ b/scripts/room-poll.sh
@@ -39,16 +39,57 @@ if [ ! -f "$CHECK_SCRIPT" ]; then
exit 1
fi
+# Durable-retry marker (codex acceptance gate, #29 blocker 3): the check
+# script advances its seen-set when it reports NEW, so a skipped/failed
+# nudge would otherwise be consumed-but-never-delivered. Persist the
+# pending state and keep retrying until a nudge fully lands.
+PENDING_FILE="${IAK_NUDGE_PENDING_FILE:-/tmp/iak_nudge_pending}"
+GUARD="${IAK_IDLE_GUARD:-$SCRIPT_DIR/../tools/human-idle-guard.sh}"
+
while true; do
HAS_NEW=$(python3 "$CHECK_SCRIPT" 2>"$ERR_LOG")
echo "[$(date -u +%FT%TZ)] Poll result: $HAS_NEW"
if [ "$HAS_NEW" = "NEW" ]; then
+ touch "$PENDING_FILE"
+ fi
+
+ if [ -f "$PENDING_FILE" ]; then
if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
- tmux send-keys -t "$TMUX_SESSION" -l "$NUDGE_TEXT"
- sleep 0.3
- tmux send-keys -t "$TMUX_SESSION" Enter
- echo "[$(date -u +%FT%TZ)] Sent short nudge"
+ # Never type over the human (petrus 2026-07-13): tmux send-keys
+ # lands in the pane regardless of window focus, so if the human
+ # is typing in that terminal this shreds their input. Guard
+ # before EACH injection; a skipped nudge stays pending and
+ # retries next cycle.
+ if ! "$GUARD"; then
+ echo "[$(date -u +%FT%TZ)] nudge deferred: human active - retrying next cycle"
+ else
+ # Pin the pane id BEFORE typing (#30 gate, medium): if the
+ # human switches the session's active pane between our
+ # keystrokes, session-targeted Enter/C-u would land in THEIR
+ # pane - C-u would wipe their input line. All three
+ # injections target the same resolved pane.
+ PANE_ID="$(tmux display-message -p -t "$TMUX_SESSION" '#{pane_id}' 2>/dev/null || true)"
+ if [ -z "$PANE_ID" ]; then
+ echo "[$(date -u +%FT%TZ)] nudge deferred: cannot resolve pane id"
+ else
+ tmux send-keys -t "$PANE_ID" -l "$NUDGE_TEXT"
+ sleep 0.3
+ if "$GUARD"; then
+ tmux send-keys -t "$PANE_ID" Enter
+ rm -f "$PENDING_FILE"
+ echo "[$(date -u +%FT%TZ)] Sent short nudge"
+ else
+ # Human became active in the 300ms window: withhold
+ # Enter rather than firing it into their flow, and
+ # erase the nudge text we typed (C-u clears OUR
+ # pinned pane's input line) so the retry doesn't
+ # double-type it.
+ tmux send-keys -t "$PANE_ID" C-u
+ echo "[$(date -u +%FT%TZ)] Enter withheld: human became active mid-nudge; input line cleared"
+ fi
+ fi
+ fi
else
echo "[$(date -u +%FT%TZ)] tmux session not found: $TMUX_SESSION"
fi
diff --git a/scripts/team-watchdog.mjs b/scripts/team-watchdog.mjs
new file mode 100644
index 0000000..79b57a8
--- /dev/null
+++ b/scripts/team-watchdog.mjs
@@ -0,0 +1,188 @@
+#!/usr/bin/env node
+// team-watchdog (claudeMB / MacBook) — keep the team alive in the room.
+//
+// Runs in the ALWAYS-ON layer (launchd / tmux), independent of any agent IDE.
+// Every INTERVAL it reads the room, computes each roster agent's last-seen, and
+// wakes any that has gone quiet too long: POST /wake (if reachable) + an
+// @mention nudge their poller catches. Rate-limited so it never spams.
+//
+// DRY_RUN=1 -> detect + log only, NO room posts / wakes (safe before Tailscale
+// to the Mini is up, since unreachable agents would just get spam).
+// Flip to active (unset DRY_RUN) once `tailscale up` is done on the Mini.
+//
+// Env: ANTFARM_KEY (defaults to claudeMB posting key), ROOM, STALE_MIN,
+// COOLDOWN_MIN, INTERVAL_MIN, MAX_NUDGES.
+
+const API = 'https://antfarm.world/api/v1';
+const HOME = process.env.HOME || '';
+const REPO = process.env.IAK_ROOT || new URL('..', import.meta.url).pathname.replace(/\/$/, '');
+const KEY = process.env.ANTFARM_KEY || JSON.parse(readFileSync(process.env.IAK_CONFIG || `${REPO}/config/macbook.json`,'utf8')).poller.api_key;
+const ROOM = process.env.ROOM || 'thinkoff-development';
+const DRY = process.env.DRY_RUN === '1';
+const STALE_MS = (Number(process.env.STALE_MIN) || 20) * 60_000;
+const COOLDOWN_MS = (Number(process.env.COOLDOWN_MIN) || 30) * 60_000;
+const INTERVAL_MS = (Number(process.env.INTERVAL_MIN) || 5) * 60_000;
+const MAX_NUDGES = Number(process.env.MAX_NUDGES) || 2; // stop room-posting after N consecutive misses (avoid perma-spam)
+// Gate-ack = liveness (claudemm, Jul 5 2026): a gated agent whose gate accepts
+// the wake nudge is demonstrably alive and just got poked, so we NEVER room-nudge
+// it - silence is an accepted state. Only a gateless agent (room @mention is its
+// wake path) or an UNREACHABLE gate produces a room post.
+
+const SELF = 'claudemb';
+// ether + hermes are set to mention_only (Jul 5 2026, after the overnight
+// flood): the ONLY thing that makes them talk is being @mentioned, so a
+// watchdog nudge to them re-creates the exact noise petrus complained about.
+// Watch only the agents that should be autonomously live in the room.
+// Roster comes from config (gitignored) or env - a public repo must not
+// hardcode tailnet IPs or machine paths (#30 gate, B3). File format:
+// config/watchdog-roster.json = [{"handle":"@x","gate":"http://host:8788"},
+// {"handle":"@y","localWake":"/abs/path.sh"}]
+function loadRoster() {
+ const fromEnv = process.env.IAK_WATCHDOG_ROSTER;
+ const file = process.env.IAK_WATCHDOG_ROSTER_FILE || `${REPO}/config/watchdog-roster.json`;
+ try {
+ if (fromEnv) return JSON.parse(fromEnv);
+ return JSON.parse(readFileSync(file, 'utf8'));
+ } catch {
+ console.log(`team-watchdog: no roster (set IAK_WATCHDOG_ROSTER or ${file}); nothing to watch`);
+ return [];
+ }
+}
+const ROSTER = loadRoster();
+/* Historical roster notes (Jul 2026):
+ // codex ADDED with a silent LOCAL wake (Jul 13 2026): its webhook-wake tunnel
+ // died silently Jul 9-12 and nobody noticed for three days. The watchdog is
+ // the backstop: if @codexmb goes quiet, run the local GUI nudge directly -
+ // never a room post (same lesson as antigravity below).
+ // antigravity REMOVED from room-nudging (petrus, Jul 5 2026: "your
+ // antigravity checks are spamming the room pls stop them"). It is gateless,
+ // so the only way to nudge it was a room @mention every ~48min, which read
+ // as spam. It runs on this MacBook, so if death-detection is wanted later,
+ // do it via a silent local process check, never a room post.
+*/
+
+// State persists to a file so it survives one-shot (StartInterval) runs and
+// sleep/wake. In-process setTimeout pauses when the Mac sleeps, so the watchdog
+// runs as a launchd StartInterval one-shot (ONCE=1) instead of a long loop.
+import { readFileSync, writeFileSync } from 'node:fs';
+import { execFile } from 'node:child_process';
+const STATE_FILE = '/tmp/team-watchdog-state.json';
+let state = {};
+try { state = JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch {}
+function saveState() { try { writeFileSync(STATE_FILE, JSON.stringify(state)); } catch {} }
+
+async function getMessages() {
+ const r = await fetch(`${API}/rooms/${ROOM}/messages?limit=80`, { headers: { 'X-API-Key': KEY }, signal: AbortSignal.timeout(15000) });
+ const d = await r.json();
+ return d.messages || [];
+}
+async function postRoom(body) {
+ if (DRY) { console.log('[dry] would post:', body); return; }
+ await fetch(`${API}/messages`, { method: 'POST', headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ room: ROOM, body }), signal: AbortSignal.timeout(15000) });
+}
+async function wakeGate(gate) {
+ if (!gate) return false;
+ try {
+ // Gates may require Bearer auth (claudemm's :8788 does since Jul 8 - every
+ // unauthenticated wake 401s, claudemm review of IAK PR #28). Token is read
+ // from a private file, never committed, never logged.
+ const headers = { 'Content-Type': 'application/json' };
+ try {
+ const tok = readFileSync(`${process.env.HOME}/.iak/gate_bearer`, 'utf8').trim();
+ if (tok) headers['Authorization'] = `Bearer ${tok}`;
+ } catch {}
+ const r = await fetch(`${gate}/wake`, { method: 'POST', headers, body: JSON.stringify({ text: 'check rooms' }), signal: AbortSignal.timeout(5000) });
+ return r.ok;
+ } catch { return false; }
+}
+function wakeLocal(script) {
+ // Local GUI nudge for same-machine agents (codex). Silent by contract:
+ // success or failure, we log and never room-post. Must pass the same env
+ // the webhook supervisor uses: codex lives INSIDE ChatGPT.app (the default
+ // app name "Codex" does not exist -> AppleScript focus ABORTs), and launchd
+ // strips PATH so the helper binaries need absolute paths.
+ if (!script) return Promise.resolve(false);
+ return new Promise((resolve) => {
+ execFile('bash', [script], {
+ timeout: 320_000, // wake waits up to 300s for human-idle (#30 gate, B2)
+ env: {
+ ...process.env,
+ IAK_CODEX_APP_NAME: 'ChatGPT',
+ IAK_NUDGE_TEXT: 'check rooms [codex]',
+ IAK_CLICLICK_BIN: '/opt/homebrew/bin/cliclick',
+ IAK_PYTHON_BIN: '/opt/homebrew/bin/python3',
+ },
+ }, (err) => resolve(!err));
+ });
+}
+function lastSeen(msgs, handle) {
+ const h = handle.replace(/^@/, '').toLowerCase();
+ let t = 0;
+ for (const m of msgs) {
+ if ((m.from || '').replace(/^@/, '').toLowerCase() === h) {
+ const ts = Date.parse(m.created_at || 0);
+ if (ts > t) t = ts;
+ }
+ }
+ return t;
+}
+
+async function tick() {
+ const msgs = await getMessages();
+ const now = Date.now();
+ const toNudge = [];
+ for (const a of ROSTER) {
+ const seen = lastSeen(msgs, a.handle);
+ const ageMin = seen ? Math.round((now - seen) / 60000) : Infinity;
+ const st = (state[a.handle] ||= { lastNudge: 0, misses: 0, silentWakes: 0 });
+ const stale = (now - (seen || 0)) > STALE_MS;
+ if (!stale) { st.misses = 0; st.silentWakes = 0; continue; }
+ if (now - st.lastNudge < COOLDOWN_MS) continue; // rate-limit
+ if (st.misses >= MAX_NUDGES) { console.log(`${a.handle} still down (giving room a rest after ${st.misses} nudges)`); continue; }
+ if (a.localWake) {
+ // Same-machine agent: silent local nudge, never a room post.
+ const poked = await wakeLocal(a.localWake);
+ st.lastNudge = now;
+ console.log(`${a.handle} quiet ${ageMin}m - local wake ${poked ? 'fired' : 'FAILED (check nudge log)'}`);
+ continue;
+ }
+ const woke = await wakeGate(a.gate);
+ if (woke) {
+ // Gate accepted the wake = the agent process is alive AND just got poked.
+ // Per the gate-ack=liveness contract (claudemm, Jul 5): silence is now an
+ // ACCEPTED state, so we never escalate a gated+gate-acking agent to a room
+ // nudge - that would just re-create the noise we removed, delayed. If a
+ // session ever does wedge, delivering this very gate wake is what recovers
+ // it (proven overnight), so the wake alone is the useful action. Stay
+ // silent; only gate UNREACHABILITY (below) is a real can't-confirm signal.
+ st.lastNudge = now;
+ console.log(`${a.handle} silent but gate-woke (alive + poked) - no room nudge`);
+ continue;
+ }
+ if (!a.gate) {
+ // Local agent (no gate): the room @mention IS its wake path, so post.
+ st.lastNudge = now; st.misses += 1;
+ toNudge.push({ handle: a.handle, ageMin, wedged: false });
+ continue;
+ }
+ // Gate present but unreachable (e.g. Mini tailscale down): a room nudge does
+ // nothing but spam - log and skip until it is reachable.
+ console.log(`${a.handle} unreachable (gate down) - skipping room nudge until reachable`);
+ }
+ if (toNudge.length) {
+ const mentions = toNudge.map(n => n.handle).join(' ');
+ const detail = toNudge.map(n => `${n.handle} quiet ${n.ageMin}m${n.wedged ? ' [gate woke but no post - possibly wedged]' : ''}`).join(', ');
+ await postRoom(`${mentions} [team-watchdog] you have gone quiet - check rooms and reply here. (${detail}; auto from claudeMB)`);
+ }
+ saveState();
+ console.log(new Date().toISOString(), `checked ${ROSTER.length}`, toNudge.length ? `nudged: ${toNudge.map(n=>n.handle).join(',')}` : 'all healthy/cooling');
+}
+
+(async () => {
+ console.log(`team-watchdog up. DRY_RUN=${DRY} stale=${STALE_MS/60000}m cooldown=${COOLDOWN_MS/60000}m interval=${INTERVAL_MS/60000}m`);
+ for (;;) {
+ try { await tick(); } catch (e) { console.error('tick error:', e.message); }
+ if (process.env.ONCE === '1') break;
+ await new Promise(r => setTimeout(r, INTERVAL_MS));
+ }
+})();
diff --git a/scripts/webhook-supervisor.sh b/scripts/webhook-supervisor.sh
new file mode 100755
index 0000000..796adbc
--- /dev/null
+++ b/scripts/webhook-supervisor.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# webhook-supervisor.sh — keep the @claudeMB webhook wake alive + self-healing.
+#
+# The cloudflared quick-tunnel URL is ephemeral (changes on restart/reboot), so
+# a static registration rots. This supervisor owns the whole chain: it runs the
+# local receiver, runs cloudflared, extracts the public URL, registers it with
+# the platform (PUT /agents/me/webhook), and then every INTERVAL seconds checks
+# that cloudflared is alive and the URL is unchanged — re-registering on any
+# change and restarting cloudflared if it died. Run under launchd for reboot
+# survival; the loop itself handles tunnel churn.
+set -u
+
+SECRET_FILE="$HOME/.iak-webhook-wake-secret"
+CONFIG="$HOME/ide-agent-kit/config/macbook.json"
+RECEIVER="$HOME/ide-agent-kit/scripts/webhook-wake.mjs"
+PORT=8790
+INTERVAL=60
+CF_LOG=/tmp/cloudflared-webhook.log
+LOG=/tmp/webhook-supervisor.log
+
+log(){ echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
+
+[ -s "$SECRET_FILE" ] || { head -c 999 /dev/urandom | base64 | tr -dc 'A-Za-z0-9_-' | head -c 32 > "$SECRET_FILE"; chmod 600 "$SECRET_FILE"; }
+SECRET=$(cat "$SECRET_FILE")
+KEY=$(python3 -c "import json;print(json.load(open('$CONFIG'))['poller']['api_key'])")
+
+start_receiver(){ pgrep -f "webhook-wake.mjs" >/dev/null || { WEBHOOK_WAKE_SECRET="$SECRET" /usr/local/bin/node "$RECEIVER" >>/tmp/webhook-wake.log 2>&1 & log "receiver started"; }; }
+
+start_tunnel(){ pkill -f "cloudflared tunnel --url http://127.0.0.1:$PORT" 2>/dev/null; sleep 1; : > "$CF_LOG"; /opt/homebrew/bin/cloudflared tunnel --url "http://127.0.0.1:$PORT" --no-autoupdate >>"$CF_LOG" 2>&1 & log "cloudflared started"; }
+
+get_url(){ for i in $(seq 1 20); do u=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" "$CF_LOG" 2>/dev/null | head -1); [ -n "$u" ] && { echo "$u"; return; }; sleep 2; done; }
+
+register(){ local hook="$1/hook/$SECRET"; python3 -c "
+import urllib.request,json,sys
+req=urllib.request.Request('https://antfarm.world/api/v1/agents/me/webhook',data=json.dumps({'webhook_url':sys.argv[1]}).encode(),method='PUT')
+req.add_header('X-API-Key',sys.argv[2]);req.add_header('Content-Type','application/json')
+print(urllib.request.urlopen(req,timeout=15).status)
+" "$hook" "$KEY" 2>&1; }
+
+log "supervisor start"
+start_receiver
+start_tunnel
+URL=$(get_url); [ -z "$URL" ] && { log "no tunnel url; retrying next loop"; }
+[ -n "$URL" ] && { r=$(register "$URL"); log "registered $URL (http $r)"; }
+
+while true; do
+ sleep "$INTERVAL"
+ start_receiver
+ if ! pgrep -f "cloudflared tunnel --url http://127.0.0.1:$PORT" >/dev/null; then
+ log "cloudflared died; restarting"; start_tunnel; NEW=$(get_url)
+ [ -n "$NEW" ] && { URL="$NEW"; r=$(register "$URL"); log "re-registered after restart $URL (http $r)"; }
+ continue
+ fi
+ NEW=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" "$CF_LOG" 2>/dev/null | tail -1)
+ if [ -n "$NEW" ] && [ "$NEW" != "$URL" ]; then
+ URL="$NEW"; r=$(register "$URL"); log "url changed -> re-registered $URL (http $r)"
+ fi
+done
diff --git a/scripts/webhook-wake.mjs b/scripts/webhook-wake.mjs
index a5086bd..3b524a0 100644
--- a/scripts/webhook-wake.mjs
+++ b/scripts/webhook-wake.mjs
@@ -14,7 +14,10 @@
//
// Env: WEBHOOK_WAKE_SECRET (required), WEBHOOK_WAKE_PORT (default 8790),
// WEBHOOK_WAKE_SCRIPT (default ../scripts/claudemb-wake.sh),
-// WEBHOOK_WAKE_SELF (default "@claudeMB" — skip own messages)
+// WEBHOOK_WAKE_SELF (default "@claudeMB" — skip own messages),
+// WEBHOOK_WAKE_OWNER (optional owner handle; wake for their messages),
+// WEBHOOK_WAKE_MENTIONS (optional comma-separated handles to wake for),
+// WEBHOOK_WAKE_LOG (default /tmp/webhook-wake.log)
import http from 'node:http';
import { execFile } from 'node:child_process';
import { appendFileSync } from 'node:fs';
@@ -28,9 +31,23 @@ if (!SECRET || SECRET.length < 16) {
}
const PORT = Number(process.env.WEBHOOK_WAKE_PORT || 8790);
const SELF = (process.env.WEBHOOK_WAKE_SELF || '@claudeMB').toLowerCase();
+const OWNER = (process.env.WEBHOOK_WAKE_OWNER || '').toLowerCase().replace(/^@/, '');
+const MENTIONS = (process.env.WEBHOOK_WAKE_MENTIONS || '')
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const WAKE = process.env.WEBHOOK_WAKE_SCRIPT ||
path.join(path.dirname(fileURLToPath(import.meta.url)), 'claudemb-wake.sh');
-const LOG = '/tmp/webhook-wake.log';
+const LOG = process.env.WEBHOOK_WAKE_LOG || '/tmp/webhook-wake.log';
+
+const textValue = (value, keys = ['handle', 'slug', 'name', 'body', 'text', 'content']) => {
+ if (value == null) return '';
+ if (typeof value === 'string' || typeof value === 'number') return String(value);
+ if (typeof value === 'object') {
+ for (const key of keys) {
+ if (value[key] != null) return textValue(value[key], keys);
+ }
+ }
+ return '';
+};
const log = (line) => {
const entry = `${new Date().toISOString()} ${line}\n`;
@@ -60,14 +77,25 @@ const server = http.createServer((req, res) => {
let from = '', body = '', room = '';
try {
const p = JSON.parse(raw);
- from = String(p.from ?? p.sender ?? p.handle ?? '');
- body = String(p.body ?? p.message ?? p.text ?? '');
- room = String(p.room ?? p.room_slug ?? '');
+ const message = p.message && typeof p.message === 'object' ? p.message : p;
+ from = textValue(message.from ?? message.sender ?? message.handle ?? p.from ?? p.sender);
+ body = textValue(message.body ?? message.text ?? message.content ?? p.body ?? p.text);
+ room = textValue(message.room ?? message.room_slug ?? p.room ?? p.room_slug);
} catch { /* non-JSON payload: still wake */ }
log(`event room=${room} from=${from} bytes=${raw.length} body=${body.slice(0, 120).replace(/\n/g, ' ')}`);
if (from.toLowerCase() === SELF) return log('skip: own message');
+ if (OWNER || MENTIONS.length) {
+ const sender = from.toLowerCase().replace(/^@/, '');
+ const lowerBody = body.toLowerCase();
+ const wanted = (OWNER && sender === OWNER) || MENTIONS.some((mention) => lowerBody.includes(mention));
+ if (!wanted) return log('skip: not owner/mention');
+ }
if (rateLimited()) return log('skip: rate limited');
- execFile('bash', [WAKE, 'check rooms'], { timeout: 30_000 }, (err) =>
+ // 320s: the wake script's human-idle guard legitimately waits up to
+ // 300s for an idle window (#30 gate, B2). The old 30s kill SIGTERMed
+ // the wait and could orphan a mid-flight osascript that typed AFTER
+ // we logged failure.
+ execFile('bash', [WAKE, 'check rooms'], { timeout: 320_000 }, (err) =>
log(err ? `wake error: ${err.message}` : 'wake fired'));
});
});
diff --git a/test/macos-human-idle.test.mjs b/test/macos-human-idle.test.mjs
new file mode 100644
index 0000000..54cc52a
--- /dev/null
+++ b/test/macos-human-idle.test.mjs
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const helper = path.join(repoRoot, 'tools', 'macos_human_idle.py');
+const python = spawnSync('python3', ['--version']).status === 0 ? 'python3' : null;
+
+function runIdleCheck(idleSeconds, thresholdSeconds = 60) {
+ const fakeModuleDir = mkdtempSync(path.join(tmpdir(), 'iak-quartz-'));
+ try {
+ writeFileSync(
+ path.join(fakeModuleDir, 'Quartz.py'),
+ `import os
+kCGEventKeyDown = 1
+kCGEventFlagsChanged = 2
+kCGEventMouseMoved = 3
+kCGEventLeftMouseDown = 4
+kCGEventRightMouseDown = 5
+kCGEventOtherMouseDown = 6
+kCGEventScrollWheel = 7
+kCGEventSourceStateCombinedSessionState = 8
+def CGEventSourceSecondsSinceLastEventType(_state, _event_type):
+ return float(os.environ['FAKE_IDLE_SECONDS'])
+`,
+ );
+ return spawnSync(python, [helper, String(thresholdSeconds)], {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ FAKE_IDLE_SECONDS: String(idleSeconds),
+ PYTHONPATH: fakeModuleDir,
+ },
+ });
+ } finally {
+ rmSync(fakeModuleDir, { recursive: true, force: true });
+ }
+}
+
+test('human-idle guard rejects recent input and clamps thresholds to 60 seconds', {
+ skip: !python,
+}, () => {
+ const result = runIdleCheck(30, 1);
+ assert.equal(result.status, 1);
+ assert.equal(result.stdout.trim(), '30.000');
+});
+
+test('human-idle guard allows injection after the safe idle window', {
+ skip: !python,
+}, () => {
+ const result = runIdleCheck(61);
+ assert.equal(result.status, 0);
+ assert.equal(result.stdout.trim(), '61.000');
+});
+
+test('human-idle guard fails closed when Quartz returns an unknown value', {
+ skip: !python,
+}, () => {
+ const result = runIdleCheck('nan');
+ assert.equal(result.status, 2);
+});
diff --git a/test/wake-guard-convergence.test.mjs b/test/wake-guard-convergence.test.mjs
new file mode 100644
index 0000000..7fba407
--- /dev/null
+++ b/test/wake-guard-convergence.test.mjs
@@ -0,0 +1,216 @@
+import assert from 'node:assert/strict';
+import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+// Convergence acceptance gate (#28 + #29, codex review):
+// - invalid-threshold: garbage IDLE_THRESHOLD_S must never authorize injection
+// - fail-closed: unreadable idle state = human active
+// - mid-focus-activity: human returning between entry check and injection
+// withholds the injection and keeps the message pending
+// - timeout-retry: a deferred nudge is retried on later cycles, never consumed
+//
+// The shell guard reads HIDIdleTime via ioreg; tests stub ioreg on PATH.
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const guard = path.join(repoRoot, 'tools', 'human-idle-guard.sh');
+const roomPoll = path.join(repoRoot, 'scripts', 'room-poll.sh');
+
+function withStubIoreg(idleNs, fn) {
+ const stubDir = mkdtempSync(path.join(tmpdir(), 'iak-ioreg-'));
+ try {
+ const body = idleNs === null
+ ? '#!/bin/sh\nexit 0\n' // ioreg runs but emits no HIDIdleTime line
+ : `#!/bin/sh\necho '| "HIDIdleTime" = ${idleNs}'\n`;
+ const stub = path.join(stubDir, 'ioreg');
+ writeFileSync(stub, body);
+ chmodSync(stub, 0o755);
+ return fn(stubDir);
+ } finally {
+ rmSync(stubDir, { recursive: true, force: true });
+ }
+}
+
+function runGuard(idleNs, env = {}) {
+ return withStubIoreg(idleNs, (stubDir) =>
+ spawnSync('bash', [guard], {
+ encoding: 'utf8',
+ env: { ...process.env, PATH: `${stubDir}:${process.env.PATH}`, ...env },
+ })
+ );
+}
+
+test('shell guard: garbage threshold clamps to 60s and still blocks recent input', () => {
+ // 30s idle with IDLE_THRESHOLD_S=not-a-number: the old bug authorized
+ // injection (exit 0). Clamped to 60s it must block.
+ const r = runGuard(30n * 1_000_000_000n, { IDLE_THRESHOLD_S: 'not-a-number' });
+ assert.equal(r.status, 1, `expected block, got ${r.status}: ${r.stderr}`);
+ assert.match(r.stderr, /invalid IDLE_THRESHOLD_S/);
+});
+
+test('shell guard: zero threshold clamps to 60s (never authorize-always)', () => {
+ const r = runGuard(5n * 1_000_000_000n, { IDLE_THRESHOLD_S: '0' });
+ assert.equal(r.status, 1);
+});
+
+test('shell guard: fails closed when HIDIdleTime is unreadable', () => {
+ const r = runGuard(null);
+ assert.equal(r.status, 1);
+ assert.match(r.stderr, /failing closed/);
+});
+
+test('shell guard: allows injection after the idle window', () => {
+ const r = runGuard(61n * 1_000_000_000n);
+ assert.equal(r.status, 0, r.stderr);
+});
+
+// End-to-end retry semantics through room-poll.sh with everything stubbed:
+// a stateful guard fails (human active) for its first N calls, then passes.
+// The nudge must stay PENDING across failed cycles and deliver once idle.
+function runRoomPollCycles({ guardFailures, cycles }) {
+ const dir = mkdtempSync(path.join(tmpdir(), 'iak-roompoll-'));
+ const tmuxLog = path.join(dir, 'tmux.log');
+ const guardState = path.join(dir, 'guard-calls');
+ const pending = path.join(dir, 'pending');
+
+ // Stub tmux: log every send-keys; has-session always true.
+ const tmuxStub = path.join(dir, 'tmux');
+ writeFileSync(tmuxStub, `#!/bin/sh
+if [ "$1" = "has-session" ]; then exit 0; fi
+if [ "$1" = "display-message" ]; then echo "%1"; exit 0; fi
+echo "$@" >> ${JSON.stringify(tmuxLog)}
+exit 0
+`);
+ chmodSync(tmuxStub, 0o755);
+
+ // Stateful stub guard: exit 1 for the first guardFailures calls, then 0.
+ const guardStub = path.join(dir, 'guard.sh');
+ writeFileSync(guardStub, `#!/bin/sh
+n=0
+[ -f ${JSON.stringify(guardState)} ] && n=$(cat ${JSON.stringify(guardState)})
+n=$((n + 1))
+echo $n > ${JSON.stringify(guardState)}
+[ "$n" -le ${guardFailures} ] && exit 1
+exit 0
+`);
+ chmodSync(guardStub, 0o755);
+
+ // Check script: NEW on the first cycle only.
+ const checkStub = path.join(dir, 'check.py');
+ writeFileSync(checkStub, `import os
+flag = ${JSON.stringify(path.join(dir, 'newed'))}
+if not os.path.exists(flag):
+ open(flag, 'w').close()
+ print("NEW")
+else:
+ print("NONE")
+`);
+
+ // macOS has no GNU `timeout` binary; spawnSync's timeout kills the loop.
+ const r = spawnSync('bash', [roomPoll], {
+ encoding: 'utf8',
+ timeout: (cycles + 1) * 1000,
+ env: {
+ ...process.env,
+ PATH: `${dir}:${process.env.PATH}`,
+ IAK_POLL_INTERVAL: '1',
+ IAK_TMUX_SESSION: 'stub-session',
+ IAK_NUDGE_TEXT: 'check rooms',
+ IAK_CHECK_SCRIPT: checkStub,
+ IAK_IDLE_GUARD: guardStub,
+ IAK_NUDGE_PENDING_FILE: pending,
+ IAK_ERR_LOG: path.join(dir, 'err.log'),
+ IAK_LOCK_FILE: path.join(dir, 'lock.pid'),
+ },
+ });
+
+ const sent = existsSync(tmuxLog) ? readFileSync(tmuxLog, 'utf8') : '';
+ const pendingLeft = existsSync(pending);
+ rmSync(dir, { recursive: true, force: true });
+ return { stdout: r.stdout ?? '', sent, pendingLeft };
+}
+
+test('room-poll: nudge deferred while human active stays pending and retries to delivery', () => {
+ // Guard fails twice (entry checks of cycles 1-2: human typing), passes
+ // from call 3 (cycle 3 entry + its pre-Enter recheck). timeout-retry gate:
+ // the NEW from cycle 1 must still deliver in cycle 3.
+ const { stdout, sent, pendingLeft } = runRoomPollCycles({ guardFailures: 2, cycles: 5 });
+ assert.match(stdout, /nudge deferred: human active/);
+ assert.match(sent, /-l check rooms/, 'nudge text should eventually be typed');
+ assert.match(sent, /Enter/, 'Enter should eventually be sent');
+ assert.equal(pendingLeft, false, 'pending marker must clear after delivery');
+});
+
+test('room-poll: idle human means clean single-cycle delivery (happy path)', () => {
+ const { sent, pendingLeft, stdout } = runRoomPollCycles({ guardFailures: 0, cycles: 2 });
+ assert.match(sent, /Enter/);
+ assert.equal(pendingLeft, false);
+ assert.doesNotMatch(stdout, /Enter withheld/);
+});
+
+test('room-poll: pre-Enter recheck failure sends C-u and retains pending', () => {
+ const dir = mkdtempSync(path.join(tmpdir(), 'iak-roompoll2-'));
+ const tmuxLog = path.join(dir, 'tmux.log');
+ const guardState = path.join(dir, 'guard-calls');
+ const pending = path.join(dir, 'pending');
+
+ const tmuxStub = path.join(dir, 'tmux');
+ writeFileSync(tmuxStub, `#!/bin/sh
+if [ "$1" = "has-session" ]; then exit 0; fi
+if [ "$1" = "display-message" ]; then echo "%1"; exit 0; fi
+echo "$@" >> ${JSON.stringify(tmuxLog)}
+exit 0
+`);
+ chmodSync(tmuxStub, 0o755);
+
+ // Pass on odd calls (entry), fail on even calls (pre-Enter recheck).
+ const guardStub = path.join(dir, 'guard.sh');
+ writeFileSync(guardStub, `#!/bin/sh
+n=0
+[ -f ${JSON.stringify(guardState)} ] && n=$(cat ${JSON.stringify(guardState)})
+n=$((n + 1))
+echo $n > ${JSON.stringify(guardState)}
+[ $((n % 2)) -eq 0 ] && exit 1
+exit 0
+`);
+ chmodSync(guardStub, 0o755);
+
+ const checkStub = path.join(dir, 'check.py');
+ writeFileSync(checkStub, `import os
+flag = ${JSON.stringify(path.join(dir, 'newed'))}
+if not os.path.exists(flag):
+ open(flag, 'w').close()
+ print("NEW")
+else:
+ print("NONE")
+`);
+
+ const r = spawnSync('bash', [roomPoll], {
+ encoding: 'utf8',
+ timeout: 3000,
+ env: {
+ ...process.env,
+ PATH: `${dir}:${process.env.PATH}`,
+ IAK_POLL_INTERVAL: '1',
+ IAK_TMUX_SESSION: 'stub-session',
+ IAK_NUDGE_TEXT: 'check rooms',
+ IAK_CHECK_SCRIPT: checkStub,
+ IAK_IDLE_GUARD: guardStub,
+ IAK_NUDGE_PENDING_FILE: pending,
+ IAK_ERR_LOG: path.join(dir, 'err.log'),
+ IAK_LOCK_FILE: path.join(dir, 'lock.pid'),
+ },
+ });
+
+ const sent = existsSync(tmuxLog) ? readFileSync(tmuxLog, 'utf8') : '';
+ const pendingLeft = existsSync(pending);
+ rmSync(dir, { recursive: true, force: true });
+
+ assert.match(r.stdout, /Enter withheld: human became active mid-nudge/);
+ assert.match(sent, /C-u/, 'typed nudge must be erased when Enter is withheld');
+ assert.doesNotMatch(sent, /(^|\n)send-keys -t %1 Enter($|\n)/, 'Enter must not fire');
+ assert.equal(pendingLeft, true, 'message must remain pending for retry');
+});
diff --git a/tools/codex_gui_nudge.sh b/tools/codex_gui_nudge.sh
index 3298cd2..24fa38e 100755
--- a/tools/codex_gui_nudge.sh
+++ b/tools/codex_gui_nudge.sh
@@ -1,9 +1,25 @@
#!/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)"
+# 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
APP_NAME="${IAK_CODEX_APP_NAME:-Codex}"
PROMPT_TEXT="${IAK_NUDGE_TEXT:-check room and respond [codex]}"
LOG_FILE="${IAK_CODEX_NUDGE_LOG:-/tmp/codex_gui_nudge.log}"
+CLICLICK_BIN="${IAK_CLICLICK_BIN:-/opt/homebrew/bin/cliclick}"
+PYTHON_BIN="${IAK_PYTHON_BIN:-/opt/homebrew/bin/python3}"
+HUMAN_IDLE_SEC="${IAK_HUMAN_IDLE_SEC:-60}"
+HUMAN_IDLE_CHECK="${IAK_HUMAN_IDLE_CHECK:-$(dirname "$0")/macos_human_idle.py}"
if ! command -v osascript >/dev/null 2>&1; then
echo "osascript not found" >&2
@@ -12,11 +28,116 @@ fi
printf '[%s] codex_gui_nudge: start app=%s text=%q\n' "$(date -u +%FT%TZ)" "$APP_NAME" "$PROMPT_TEXT" >>"$LOG_FILE"
-osascript - "$APP_NAME" "$PROMPT_TEXT" "$LOG_FILE" <<'APPLESCRIPT'
+# NEVER type into a locked screen: on a locked Mac the keystrokes land in the
+# lock-screen password field and register as failed unlock attempts
+# (claudemm review of IAK PR #28, confirmed Jul 13). Abort softly when locked
+# or when the lock state cannot be determined.
+LOCK_PY="$PYTHON_BIN"; [ -x "$LOCK_PY" ] || LOCK_PY="$(command -v python3 || true)"
+if [ -n "$LOCK_PY" ]; then
+ LOCK_STATE=$("$LOCK_PY" - <<'PY' 2>/dev/null || echo unknown
+import Quartz
+d = dict(Quartz.CGSessionCopyCurrentDictionary() or {})
+print("locked" if d.get("CGSSessionScreenIsLocked", False) else "unlocked")
+PY
+)
+else
+ LOCK_STATE=unknown
+fi
+if [ "$LOCK_STATE" != "unlocked" ]; then
+ printf '[%s] codex_gui_nudge: ABORT screen %s - refusing to type\n' "$(date -u +%FT%TZ)" "$LOCK_STATE" >>"$LOG_FILE"
+ # M3 (#30 gate): abort = NOT delivered; exit 1 so the caller retries
+ exit 1
+fi
+
+# GUI injection is a last-resort wake path. Never focus, click, paste, or press
+# Enter while the human has used the keyboard or pointer in the last minute.
+# Unknown idle state fails closed. Recheck immediately before each injection to
+# close the race where the human starts typing while Codex is being focused.
+require_human_idle() {
+ local phase="$1" idle_seconds rc
+ if [ ! -x "$LOCK_PY" ] || [ ! -f "$HUMAN_IDLE_CHECK" ]; then
+ printf '[%s] codex_gui_nudge: ABORT human-idle check unavailable at %s\n' "$(date -u +%FT%TZ)" "$phase" >>"$LOG_FILE"
+ return 1
+ fi
+ if idle_seconds=$("$LOCK_PY" "$HUMAN_IDLE_CHECK" "$HUMAN_IDLE_SEC" 2>/dev/null); then
+ return 0
+ else
+ rc=$?
+ fi
+ if [ "$rc" -eq 1 ] && [ -n "$idle_seconds" ]; then
+ printf '[%s] codex_gui_nudge: ABORT human active at %s (idle=%ss, required=%ss)\n' "$(date -u +%FT%TZ)" "$phase" "$idle_seconds" "$HUMAN_IDLE_SEC" >>"$LOG_FILE"
+ else
+ printf '[%s] codex_gui_nudge: ABORT human-idle state unknown at %s\n' "$(date -u +%FT%TZ)" "$phase" >>"$LOG_FILE"
+ fi
+ return 1
+}
+
+require_human_idle "wake start" || exit 1
+
+# Prefer cliclick for background wakes. launchd's /usr/bin/osascript process is
+# not necessarily granted Accessibility access even when Terminal is, while the
+# already-approved cliclick binary can generate the click and keystrokes. Quartz
+# window metadata gives stable coordinates without Accessibility/UI scripting.
+if [ -x "$CLICLICK_BIN" ] && [ -x "$PYTHON_BIN" ] && "$PYTHON_BIN" -c 'import Quartz' >/dev/null 2>&1; then
+ open -a "$APP_NAME"
+ sleep 0.6
+ # Guard: only type if the target app is actually FRONTMOST. cliclick clicks
+ # window-relative coordinates, and when another window overlaps that point
+ # (or focus shifts between open and click) the nudge text lands in whatever
+ # is on top - observed leaking "check rooms [codex]" into a Claude Code CLI
+ # on Jul 13. Abort softly instead.
+ FRONT_APP=$(osascript -e 'tell application "System Events" to get name of first process whose frontmost is true' 2>/dev/null || echo unknown)
+ if [ "$FRONT_APP" != "$APP_NAME" ]; then
+ printf '[%s] codex_gui_nudge: ABORT frontmost is %q not %q - refusing to type\n' "$(date -u +%FT%TZ)" "$FRONT_APP" "$APP_NAME" >>"$LOG_FILE"
+ exit 1
+ fi
+ WINDOW_BOUNDS=$("$PYTHON_BIN" - "$APP_NAME" <<'PY'
+import sys
+import Quartz
+
+app = sys.argv[1]
+windows = Quartz.CGWindowListCopyWindowInfo(
+ Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements,
+ Quartz.kCGNullWindowID,
+)
+candidates = []
+for window in windows:
+ if window.get(Quartz.kCGWindowOwnerName) != app:
+ continue
+ if int(window.get(Quartz.kCGWindowLayer, 1)) != 0:
+ continue
+ bounds = window.get(Quartz.kCGWindowBounds, {})
+ width = int(bounds.get('Width', 0))
+ height = int(bounds.get('Height', 0))
+ if width > 300 and height > 300:
+ candidates.append((width * height, int(bounds['X']), int(bounds['Y']), width, height))
+if candidates:
+ _, x, y, width, height = max(candidates)
+ print(x, y, width, height)
+PY
+)
+ if read -r WIN_X WIN_Y WIN_W WIN_H <<<"$WINDOW_BOUNDS" && [ -n "${WIN_H:-}" ]; then
+ CLICK_X=$((WIN_X + WIN_W / 2))
+ CLICK_Y=$((WIN_Y + WIN_H - 72))
+ require_human_idle "before cliclick injection" || exit 1
+ if "$CLICLICK_BIN" -r -w 20 "c:${CLICK_X},${CLICK_Y}" "t:${PROMPT_TEXT}" kp:return; then
+ printf '[%s] codex_gui_nudge: sent via cliclick x=%s y=%s\n' "$(date -u +%FT%TZ)" "$CLICK_X" "$CLICK_Y" >>"$LOG_FILE"
+ exit 0
+ fi
+ fi
+ printf '[%s] codex_gui_nudge: cliclick path unavailable; falling back to AppleScript\n' "$(date -u +%FT%TZ)" >>"$LOG_FILE"
+fi
+
+require_human_idle "before AppleScript wake" || exit 0
+
+osascript - "$APP_NAME" "$PROMPT_TEXT" "$LOG_FILE" "$LOCK_PY" "$HUMAN_IDLE_CHECK" "$HUMAN_IDLE_SEC" <<'APPLESCRIPT'
on run argv
set appName to item 1 of argv
set promptText to item 2 of argv
set logFile to item 3 of argv
+ set pythonBin to item 4 of argv
+ set idleCheck to item 5 of argv
+ set idleThreshold to item 6 of argv
my writeLog(logFile, "applescript start")
@@ -68,6 +189,11 @@ on run argv
error "could not focus " & appName number 1001
end if
+ if not my humanIsIdle(pythonBin, idleCheck, idleThreshold) then
+ my writeLog(logFile, "ABORT human active or idle state unknown before AppleScript injection")
+ return
+ end if
+
try
tell application "System Events"
tell process appName
@@ -103,6 +229,15 @@ on run argv
end try
end run
+on humanIsIdle(pythonBin, idleCheck, idleThreshold)
+ try
+ do shell script quoted form of pythonBin & " " & quoted form of idleCheck & " " & quoted form of idleThreshold
+ return true
+ on error
+ return false
+ end try
+end humanIsIdle
+
on writeLog(logFile, msg)
do shell script "printf '[%s] %s\\n' \"$(date -u +%FT%TZ)\" " & quoted form of ("codex_gui_nudge: " & msg) & " >> " & quoted form of logFile
end writeLog
diff --git a/tools/gemini_gui_nudge.sh b/tools/gemini_gui_nudge.sh
index abd36c1..d7b21aa 100755
--- a/tools/gemini_gui_nudge.sh
+++ b/tools/gemini_gui_nudge.sh
@@ -1,4 +1,16 @@
#!/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)"
+# 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
APP_NAME="${IAK_GEMINI_APP_NAME:-Claude}"
@@ -9,10 +21,45 @@ if ! command -v osascript >/dev/null 2>&1; then
exit 1
fi
-osascript - "$APP_NAME" "$PROMPT_TEXT" <<'APPLESCRIPT'
+GUARD="$REPO_ROOT/tools/human-idle-guard.sh"
+
+osascript - "$APP_NAME" "$PROMPT_TEXT" "$GUARD" <<'APPLESCRIPT'
+-- Point-of-injection recheck (codex acceptance gate, #29 blocker 1): the
+-- focus loop below burns up to 7.5s after the entry guard passed; the human
+-- can return in that window. Re-verify fresh human-idle immediately before
+-- the FIRST injection. Fail closed. (No recheck after our own keystrokes -
+-- they reset HIDIdleTime and would always false-abort.)
+on idleOk(guardPath)
+ try
+ do shell script "IDLE_THRESHOLD_S=5 " & quoted form of guardPath
+ return true
+ on error
+ return false
+ end try
+end idleOk
+
on run argv
set appName to item 1 of argv
set promptText to item 2 of argv
+ set guardPath to item 3 of argv
+
+ -- M1 (#30 gate): remember the human's app and restore it on ANY abort -
+ -- previously an error path left focus stolen on the agent app, so the
+ -- returning human typed into it (the original incident inverted).
+ tell application "System Events"
+ set frontApp to name of first application process whose frontmost is true
+ end tell
+ try
+ doWake(appName, promptText, guardPath)
+ on error errMsg number errNum
+ try
+ tell application frontApp to activate
+ end try
+ error errMsg number errNum
+ end try
+end run
+
+on doWake(appName, promptText, guardPath)
-- v0.7.2 — process-targeted keystroke (matches scripts/claudemb-wake.sh).
--
@@ -42,6 +89,9 @@ on run argv
on error
end try
if promptAlreadyTyped then
+ if not idleOk(guardPath) then
+ error "gui_nudge: human active at pre-Enter (prompt already typed)" number 86
+ end if
log "gui_nudge: prompt already typed - sending Enter"
tell application "System Events"
tell process appName
@@ -77,6 +127,11 @@ on run argv
set focusOk to false
set focusAttempts to 0
repeat while focusAttempts < 15
+ -- M1 (#30 gate): if the human returns during this up-to-7.5s loop,
+ -- abort instead of wrestling them for focus.
+ if not idleOk(guardPath) then
+ error "gui_nudge: human returned during focus loop" number 86
+ end if
try
tell application "System Events"
tell process appName
@@ -107,6 +162,9 @@ on run argv
error "gui_nudge could not bring " & appName & " to front" number 100
end if
+ if not idleOk(guardPath) then
+ error "gui_nudge: human active at pre-keystroke (after focus loop)" number 86
+ end if
try
tell application "System Events"
tell process appName
@@ -120,7 +178,12 @@ on run argv
set midFront to name of first application process whose frontmost is true
end tell
if midFront is not appName then
- log "gui_nudge: WARN — focus left " & appName & " mid-keystroke (now " & midFront & "); skipping Enter"
+ -- M2-consistency (#30 gate): focus loss after typing aborts (86)
+ -- instead of silently skipping Enter with exit 0 - the caller
+ -- retries and the promptAlreadyTyped branch delivers Enter once
+ -- the machine is idle. Exit-0-on-skip marked undelivered wakes
+ -- as delivered.
+ error "gui_nudge: focus left " & appName & " mid-keystroke (now " & midFront & ")" number 86
else
tell application "System Events"
tell process appName
@@ -128,12 +191,17 @@ on run argv
end tell
end tell
end if
+ on error errMsg number errNum
+ -- bare try would SWALLOW the 86 abort raised above; re-raise it and
+ -- tolerate only genuine midFront-check failures.
+ if errNum is 86 then error errMsg number errNum
end try
on error errMsg number errNum
+ if errNum is 86 then error errMsg number errNum
log "gui_nudge: keystroke failed — " & errNum & " " & errMsg
if errNum is 1002 then
log "gui_nudge: HINT — accessibility permission revoked. Re-grant in System Settings → Privacy & Security → Accessibility."
end if
end try
-end run
+end doWake
APPLESCRIPT
diff --git a/tools/human-idle-guard.sh b/tools/human-idle-guard.sh
new file mode 100755
index 0000000..f0bec59
--- /dev/null
+++ b/tools/human-idle-guard.sh
@@ -0,0 +1,64 @@
+#!/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}"
+# 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_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
+
+check_once
diff --git a/tools/macos_human_idle.py b/tools/macos_human_idle.py
new file mode 100755
index 0000000..53ad878
--- /dev/null
+++ b/tools/macos_human_idle.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Report whether recent keyboard or pointer input makes GUI injection unsafe."""
+
+import math
+import sys
+
+import Quartz
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ return 2
+
+ try:
+ threshold_seconds = max(60.0, float(sys.argv[1]))
+ except ValueError:
+ return 2
+
+ event_types = (
+ Quartz.kCGEventKeyDown,
+ Quartz.kCGEventFlagsChanged,
+ Quartz.kCGEventMouseMoved,
+ Quartz.kCGEventLeftMouseDown,
+ Quartz.kCGEventRightMouseDown,
+ Quartz.kCGEventOtherMouseDown,
+ Quartz.kCGEventScrollWheel,
+ )
+ idle_seconds = min(
+ float(
+ Quartz.CGEventSourceSecondsSinceLastEventType(
+ Quartz.kCGEventSourceStateCombinedSessionState,
+ event_type,
+ )
+ )
+ for event_type in event_types
+ )
+ if not math.isfinite(idle_seconds) or idle_seconds < 0:
+ return 2
+
+ print(f"{idle_seconds:.3f}")
+ return 0 if idle_seconds >= threshold_seconds else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())