Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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__/
42 changes: 33 additions & 9 deletions scripts/claude-gui-poll.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the GUI poller alive after wake failures

When $WAKE_SCRIPT exits non-zero (for example the new idle-guard timeout or AppleScript abort path), this return 1 runs in a script with set -euo pipefail; the main loop invokes it as [[ -s "$NEW_FILE" ]] && do_wake, so Bash exits on the failed last command instead of sleeping and retrying pending content. In the human-active/focus-failure scenario this stops the poller entirely, leaving future messages unpolled until something restarts it.

Useful? React with 👍 / 👎.

fi
return 0
}

parse_msgs='
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A cooldown skip is still recorded as successful delivery. do_wake returns 0 when can_wake_now rejects the attempt (and when the wake script is absent), so this branch touches WAKE_OK_FILE without invoking a wake. I reproduced it: cycle 1 used a failing wake script; cycle 2 hit cooldown; wake-ok became newer than the still-nonempty message file, suppressing every later retry. Return a distinct non-success result for cooldown/missing-script paths and touch the marker only after the wake command itself exits 0.

touch "$WAKE_OK_FILE"
fi
fi
fi

sleep "$POLL_INTERVAL"
done
25 changes: 23 additions & 2 deletions scripts/claude-gui-wake.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
27 changes: 23 additions & 4 deletions scripts/claudemb-poll.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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)"
Expand Down
143 changes: 91 additions & 52 deletions scripts/claudemb-wake.sh
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not block the polling loop for up to five minutes. Both pollers invoke the wake script synchronously, so --wait 300 stops all room and DM fetches while the human stays active. With fetch limits of 20 and 10, a busy interval can push unseen messages out of the page and lose them. Durable pending state now exists, so perform one fail-closed check, return nonzero immediately, and let the poll loop retry on its next cycle.

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).
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This recheck happens after Claude has already been activated and forced frontmost for up to 3.5 seconds. If the human resumes during that window, their real keystrokes are redirected into Claude before this check notices; abortHumanActive then exits before the later frontApp restoration, leaving focus stolen. The same ordering exists in the Gemini/Codex paths. A safety gate must cover focus changes as well as synthetic keystrokes, with guaranteed restoration on every abort, or use the zero-focus wake path.

try
tell application "System Events"
tell process appName
Expand All @@ -199,61 +259,36 @@ 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
end tell
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."
Expand All @@ -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
Loading
Loading