- 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
12 KiB
Prompt-Lock / Input Delivery Failure — Code-Level Analysis (Creator Claude)
- Analyst: Creator Claude (
canary-projects-multi-agent-mux-creator-claude) - Date: 2026-07-11
- Brief:
.mam/reports/brief-prompt-lock-fix.md - Roadmap linkage: this is the concrete design for FW-W2 ("블라인드 TUI 키 입력 방지를 위한 실행 준비도 검증", FUTURE_WORKS P2) — landing this closes that item.
1. Symptom → Root Cause → Code Mapping
The reported symptom — instruction text visible in the prompt box but never submitted, or a frozen cursor — is reproducible from the current code through three distinct paths:
| # | Root cause (brief) | Code path that triggers it |
|---|---|---|
| RC-A | Renderer thread bottleneck during heavy output | inject_instructions() pastes, sleeps a fixed 0.5 s, sends one blind C-m. If the TUI (Ink/Blessed) is still flushing startup output, the paste lands but the Enter is consumed while the input widget isn't accepting submits → text sits unsubmitted forever. No verification, no retry. |
| RC-B | Permission/trust dialog steals focus | wait_for_tui_ready() classifies dialogs as "ready" (see §2-B), so injection proceeds while a modal is up: the pasted text is swallowed by the dialog widget and the C-m blindly activates whatever dialog button is focused. |
| RC-C | OAuth / list-selection blocks intercept keys | Same as RC-B (no dialog detection anywhere), plus the resume workflow's unconditional Enter/Down/Enter sequence, which malfunctions in both directions (§2-C). |
Downstream damage: when injection silently fails on a delegated job, no started event is ever published — the delegator waits until watchdog timeout, and the pane holds a zombie prompt. The failure is invisible because inject_instructions() always returns 0.
2. Exact Code Locations (Deliverable 1)
2-A. lib.sh:1088-1100 — inject_instructions() — primary defect
$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
Sole caller: create_session.sh:384 (every --submit-job / --onboard session). Defects: fixed delay instead of readiness evidence; single unverified C-m; no dialog check before pasting; no success/failure contract. All three root causes converge here.
2-B. lib.sh:1042-1084 — wait_for_tui_ready() — defective gate
- The claude readiness regex (
lib.sh:1056) is"Anthropic|Assistant|Chat|Dangerously|dangerously|Enter|Welcome|projects". The trust/bypass dialogs themselves contain "Enter" and "Dangerously", so an open modal is reported as "✅ ready" and injection fires straight into it (RC-B). - On timeout it prints a warning and "Proceeding anyway" (
lib.sh:1083) with no failure return — the caller cannot distinguish ready from not-ready. - "Banner text painted" is the wrong readiness signal; it says nothing about the input box accepting keys (RC-A).
2-C. multi-agent-mux-resume/SKILL.md:150-156 — blind dialog navigation
sleep 5; tmux send-keys -t "$SESSION_NAME" Enter
sleep 3; tmux send-keys -t "$SESSION_NAME" Down
sleep 0.3; tmux send-keys -t "$SESSION_NAME" Enter
Sent unconditionally after claude resume. Two failure modes: (a) if no dialog appears, Down puts the fresh prompt into history navigation and the second Enter can re-submit a historical prompt — spurious re-execution; (b) if the dialog appears later than 5 s under load, the keys land in the prompt and the dialog then blocks all subsequent input — the exact lock symptom. Note the asymmetry: the create path has no dialog handling while resume has blind handling; neither is correct.
2-D. multi-agent-mux-stop/scripts/stop_session.sh:181-199 — graceful_stop()
tmux send-keys -t "$SESSION_NAME" "$exitkey" Enter (line 192) is blind: with a dialog open, /exit is swallowed, the 3 s check fails, and the session escalates to kill-session/SIGKILL — losing the agent's own state flush. Mitigated by the fallback chain (severity: low), but it produces avoidable hard-kills.
2-E. multi-agent-mux-create/SKILL.md:214-215 — documented probe
tmux send-keys -t "$SESSION_NAME" "" Enter instructs operators to fire a stray Enter as a liveness probe — with a dialog up, this blindly accepts its focused default. Documentation fix.
2-F. Checked and NOT vulnerable (per brief scope)
multi-agent-mux-resume/scripts/update_yaml_resumed.sh— pure registry update; contains nosend-keys/paste-buffer. No change needed.scripts/install_mam.sh— the epilogue only prints quick-start commands for a human; it never drives a TUI. No direct vulnerability; its create quick-start simply funnels into site 2-A, which the fix below covers.MULTI_AGENT_RULES.ko.md:122already mandates file-based briefs over long serialized typing — correct policy, but insufficient: this incident shows even the shortRead <brief> and execute.line needs guaranteed delivery.
3. Proposed Prevention Helper (Deliverable 2)
Add to lib.sh (next to the existing pane helpers). Three functions; send_keys_safe is the public entry point.
# ---------------------------------------------------------------------------
# Prompt-lock safe delivery (FW-W2). Contract: send_keys_safe returns 0 only
# if the text was verifiably submitted; callers must handle non-zero.
# ---------------------------------------------------------------------------
_sks_tmux() { # server-aware tmux (same rule as inject_instructions)
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.
_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). Tokens must NOT appear in normal
# prompt idle screens; keep this list curated per agent TUI release.
_pane_dialog_open() {
_pane_capture "$1" | grep -Eq \
'Do you trust the files|Yes, proceed|No, exit|Approve\b|Allow this|Deny\b|Press Enter to continue|Sign in|browser to authenticate|Use arrow keys|Esc to cancel'
}
# send_keys_safe <sess> <text> [job_id]
# 1. Wait for renderer quiescence (defeats RC-A).
# 2. Refuse to paste while a dialog is open (defeats RC-B/RC-C): wait up to
# SKS_DIALOG_TIMEOUT (default 30 s) for it to clear; if SKS_DIALOG_ESCAPE=1
# send a single Escape and re-check. NEVER a blind Enter — accepting an
# unknown dialog is a policy decision, not a delivery detail.
# 3. Paste via unique buffer; verify the text landed (marker visible in pane).
# 4. Submit C-m; verify submission (marker left the input area); retry the
# C-m up to 3 times with backoff — safe because re-Enter on the same
# unsubmitted text is idempotent.
send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}"
local marker deadline
marker=$(printf '%s' "$text" | head -c 200 | tail -c 24) # verification token
_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
[ "$(date +%s)" -ge "$deadline" ] && { echo "send_keys_safe: dialog blocking input ($sess)" >&2; return 2; }
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; }
local try
for try in 1 2 3; do
_sks_tmux send-keys -t "$sess" C-m
sleep "$try"
if ! _pane_capture "$sess" | tail -n 5 | grep -Fq "$marker"; then
return 0 # input box cleared → submitted
fi
done
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
return 4
}
Design decisions worth recording:
- Quiescence over fixed sleeps: two identical captures prove the renderer drained its queue — directly addresses the Blessed/Ink bottleneck; a fixed
sleepcan only ever be wrong in one direction or the other. - Escape opt-in, never blind Enter/Ctrl+C:
Escapecancels dialogs but also clears typed prompt text in some TUIs, andCtrl+Ccan interrupt a running agent turn — so focus restoration is gated behindSKS_DIALOG_ESCAPE=1and only fires when a dialog signature is positively detected. Default behavior is to wait and then fail loudly (distinct exit codes 1-4 tell the caller what blocked). - Marker-based submit verification: agent-agnostic — no per-TUI spinner parsing. The last 24 chars of the text must appear after paste and must leave the bottom 5 lines after Enter. Retrying Enter while the marker is still in the input box is idempotent.
- Distinct non-zero exit codes let
create_session.shpublish a preciseerrorevent instead of leaving a zombie job.
4. Draft Migration Plan (Deliverable 3)
| Step | Change | Files | Risk |
|---|---|---|---|
| M1 | Add the three helpers; rewrite inject_instructions() body as a thin wrapper over send_keys_safe (same signature). Sole caller create_session.sh:384 inherits the fix with zero call-site change; add return-code check that publishes error + lets the still-armed cleanup trap roll the session back. |
lib.sh, create_session.sh |
Low — single choke point |
| M2 | Harden wait_for_tui_ready: drop dialog-ambiguous tokens (Enter, Dangerously, dangerously) from the claude regex; treat _pane_dialog_open as not ready; make timeout return 1 and let the caller decide (delegated-job path should abort + rollback rather than "proceed anyway"). |
lib.sh |
Low |
| M3 | Resume workflow: replace the unconditional Enter/Down/Enter block with a conditional loop — poll _pane_dialog_open; send navigation keys only when a trust-dialog signature is actually present; skip cleanly otherwise. |
multi-agent-mux-resume/SKILL.md (embedded shell) |
Medium — needs scratch-spawn validation |
| M4 | Stop graceful path: before sending $exitkey, run the dialog check (+ optional single Escape); deliver exitkey via send_keys_safe; keep the SIGTERM/SIGKILL fallback chain untouched. |
stop_session.sh |
Low |
| M5 | Docs: fix the stray-Enter probe example (create/SKILL.md:214-215) to use capture-pane readiness; document send_keys_safe in create/delegate-job SKILL.md; mark FW-W2 resolved in FUTURE_WORKS.md / .ko.md. |
docs only | None |
Verification gate (DoD) — all on a scratch tmux server (-L sks-test), never real sessions:
- RC-A stress: mock TUI that floods output for 10 s before reading stdin →
send_keys_safemust wait, then deliver; oldinject_instructionsdemonstrably drops the Enter. - RC-B/C dialog: mock script printing a trust-dialog signature and swallowing keys → helper must refuse to paste, honor timeout/Escape policy, and return code 2.
- E2E regression: real
create --submit-jobon a scratch workspace → instructions submitted,startedevent observed; normal create/stop/resume unchanged. bash -n+shellcheckonlib.sh,create_session.sh,stop_session.sh: 0 new findings.- Estimated diff: ~70 lines added to
lib.sh, <15 lines each elsewhere.
5. Summary
Every injection site in the codebase shares one flaw: keys are sent on a timer, not on evidence. The fix is a single evidence-based delivery helper (send_keys_safe: quiescence → dialog gate → paste-verify → submit-verify-retry) plus honesty in the readiness gate (wait_for_tui_ready must not call a modal dialog "ready" and must be allowed to fail). Migration touches one library, two scripts, and two docs, and closes roadmap item FW-W2.