# 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** ```bash $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 ```bash 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 no `send-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:122` already mandates file-based briefs over long serialized typing — correct policy, but insufficient: this incident shows even the short `Read 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. ```bash # --------------------------------------------------------------------------- # 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 [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 — 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 [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 `sleep` can only ever be wrong in one direction or the other. - **Escape opt-in, never blind Enter/Ctrl+C**: `Escape` cancels dialogs but *also* clears typed prompt text in some TUIs, and `Ctrl+C` can interrupt a running agent turn — so focus restoration is gated behind `SKS_DIALOG_ESCAPE=1` and 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.sh` publish a precise `error` event 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: 1. **RC-A stress**: mock TUI that floods output for 10 s before reading stdin → `send_keys_safe` must wait, then deliver; old `inject_instructions` demonstrably drops the Enter. 2. **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. 3. **E2E regression**: real `create --submit-job` on a scratch workspace → instructions submitted, `started` event observed; normal create/stop/resume unchanged. 4. `bash -n` + `shellcheck` on `lib.sh`, `create_session.sh`, `stop_session.sh`: 0 new findings. 5. 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.