fix(skills): evidence-based prompt delivery (send_keys_safe) to end prompt-lock (FW-W2)

- Add send_keys_safe + quiescence/dialog-detection helpers to lib.sh:
  keys are sent on pane evidence, never fixed timers; distinct exit
  codes 1-4; dialogs are never blindly Enter-ed
- inject_instructions delegates to send_keys_safe; create --submit-job
  publishes a terminal error event on delivery failure (no zombie jobs)
- wait_for_tui_ready: drop dialog-ambiguous tokens, treat open dialogs
  as not-ready, return 1 on timeout instead of proceeding
- delegate-job wrapper: replace copy-pasted raw paste/C-m block with
  lib.sh send_keys_safe (restores single source of truth)
- resume: conditional signature-gated dialog handling replaces blind
  Enter/Down/Enter; stop --graceful delivers exitkey safely, fallback
  chain unchanged
- create SKILL: passive capture-pane probe instead of stray Enter
- Mark FW-W2 resolved; add 3-agent analysis/plan reports
This commit is contained in:
2026-07-11 09:24:34 +09:00
parent 7fc6c8c7b9
commit e613f4aedb
11 changed files with 673 additions and 37 deletions
+123 -17
View File
@@ -1037,23 +1037,25 @@ start_watchdog() {
}
# wait_for_tui_ready <session_name> <agent>
# Gated wait to ensure that the agent's TUI is fully loaded and responsive
# before injecting any keyboard instructions.
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
wait_for_tui_ready() {
local sess="$1" agent="$2"
local local_tmux="tmux"
local local_tmux="tmux" i
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local_tmux="tmux -L $TMUX_SERVER_NAME"
fi
echo "🔍 Waiting for $agent TUI to become ready..."
for i in {1..15}; do
if _pane_dialog_open "$sess"; then
sleep 1
continue
fi
local content
content=$($local_tmux capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then
case "$agent" in
claude)
if echo "$content" | grep -E -q "Anthropic|Assistant|Chat|Dangerously|dangerously|Enter|Welcome|projects" 2>/dev/null; then
if echo "$content" | grep -E -q "Anthropic|Assistant|Chat|Welcome|projects" 2>/dev/null; then
echo "✅ Claude TUI detected ready."
return 0
fi
@@ -1080,23 +1082,127 @@ wait_for_tui_ready() {
fi
sleep 1
done
echo "⚠️ Warning: TUI readiness check timed out. Proceeding anyway..."
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
return 1
}
# inject_instructions <session_name> <instructions> [job_id]
# Injects instructions into the tmux session using paste-buffer and C-m.
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
inject_instructions() {
local sess="$1" instructions="$2" job_id="${3:-onboard}"
local local_tmux="tmux"
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local_tmux="tmux -L $TMUX_SERVER_NAME"
fi
$local_tmux set-buffer -b "job_buf_$job_id" "$instructions"
$local_tmux paste-buffer -b "job_buf_$job_id" -t "$sess"
sleep 0.5
$local_tmux send-keys -t "$sess" C-m
$local_tmux delete-buffer -b "job_buf_$job_id"
send_keys_safe "$1" "$2" "${3:-onboard}"
}
# ---------------------------------------------------------------------------
# Prompt-lock safe delivery (FW-W2). Keys are sent on evidence, not timers.
# send_keys_safe returns 0 only if the text was verifiably submitted.
# Exit codes: 1=pane never quiesced 2=dialog blocking input
# 3=paste not visible 4=Enter not accepted
# Callers MUST handle non-zero.
# ---------------------------------------------------------------------------
# Server-aware tmux (same isolation rule as inject_instructions).
_sks_tmux() {
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
tmux -L "$TMUX_SERVER_NAME" "$@"
else
tmux "$@"
fi
}
_pane_capture() { _sks_tmux capture-pane -p -t "$1" 2>/dev/null || echo ""; }
# _pane_quiescent <sess> [tries=20] [interval=0.5]
# Renderer settled = two consecutive identical non-empty captures.
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
_pane_quiescent() {
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
for ((i = 0; i < tries; i++)); do
cur=$(_pane_capture "$sess")
[ -n "$cur" ] && [ "$cur" = "$prev" ] && return 0
prev="$cur"
sleep "$interval"
done
return 1
}
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
# only (dialogs render near the input area; conversation text above must not
# trigger this). Tokens must NOT appear on normal idle prompt screens.
_pane_dialog_open() {
_pane_capture "$1" | tail -n 20 | grep -Eq \
'Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel'
}
# send_keys_safe <sess> <text> [job_id]
# 1. Wait for renderer quiescence (RC-A).
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
# Escape per poll and re-check. NEVER a blind Enter.
# 3. Paste via unique buffer; verify the text landed (marker visible).
# 4. Submit C-m; verify submission (marker left the input area AND the pane
# changed); retry up to 3 times.
send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}"
local marker pre_submit deadline try
# Verification token: last 24 chars of the last non-empty line (multi-line safe).
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | tail -c 24)
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
while _pane_dialog_open "$sess"; do
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
_sks_tmux send-keys -t "$sess" Escape
sleep 1
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "send_keys_safe: dialog blocking input ($sess)" >&2
return 2
fi
sleep 2
done
_sks_tmux set-buffer -b "sks_$job_id" "$text"
_sks_tmux paste-buffer -b "sks_$job_id" -t "$sess"
_sks_tmux delete-buffer -b "sks_$job_id" 2>/dev/null || true
sleep 0.5
_pane_capture "$sess" | grep -Fq "$marker" || { echo "send_keys_safe: paste not visible ($sess)" >&2; return 3; }
for try in 1 2 3; do
pre_submit=$(_pane_capture "$sess")
_sks_tmux send-keys -t "$sess" C-m
sleep "$try"
# Submitted = input area released the text AND rendering changed after Enter.
if ! _pane_capture "$sess" | tail -n 3 | grep -Fq "$marker" \
&& [ "$(_pane_capture "$sess")" != "$pre_submit" ]; then
return 0
fi
done
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
return 4
}
# handle_startup_dialogs <sess> [timeout_sec=20]
# Post-start/resume dialog policy for claude: accept the trust / bypass
# dialogs ONLY when their signature is positively on screen; return as soon
# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence.
handle_startup_dialogs() {
local sess="$1" timeout="${2:-20}" waited=0 pane
while [ "$waited" -lt "$timeout" ]; do
pane=$(_pane_capture "$sess" | tail -n 20)
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
_sks_tmux send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
_sks_tmux send-keys -t "$sess" Down
sleep 0.3
_sks_tmux send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -Eq 'Anthropic|Assistant|Chat|Welcome|projects'; then
return 0
fi
sleep 2
waited=$((waited + 2))
done
return 0
}