fix(tui): establish 2-tier readiness model, modal/hint token separation, and adapter modal contracts
- Separate TUI dialog detection into blocking gate layer (_MAM_MODAL_TOKENS) and recovery layer (_MAM_HINT_TOKENS) to eliminate idle dialog starvation - Add modal_tokens contract across agent adapters (claude, cline) with dynamic scope facts resolution in send_keys_safe - Implement fail-closed resolution for ambiguous same-kind panes (R-1) and enforce WORKSPACE_ROOT export (R-2) - Preserve session on TUI readiness timeout when PID is alive for diagnostics - Add comprehensive test suite in tests/test_c1_tui_readiness.py and adapter contract assertions in test_a4
This commit is contained in:
+156
-33
@@ -24,6 +24,7 @@ fi
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
|
||||
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
||||
export WORKSPACE_ROOT
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
_sanitize_herdr_agent_name() {
|
||||
@@ -43,12 +44,14 @@ _sanitize_herdr_agent_name() {
|
||||
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
||||
else
|
||||
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
||||
fi
|
||||
s="${s:0:23}-${h}"
|
||||
if [ -n "$h" ]; then
|
||||
s="${s:0:23}-$h"
|
||||
else
|
||||
s="${s:0:32}"
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "${s:0:32}"
|
||||
printf '%s\n' "$s"
|
||||
}
|
||||
|
||||
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
||||
@@ -59,7 +62,9 @@ for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME
|
||||
done
|
||||
|
||||
# Central TUI dialog and readiness validation tokens (OP-6)
|
||||
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
||||
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
||||
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
||||
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
@@ -348,17 +353,18 @@ _resolve_herdr_pane_id() {
|
||||
local ws_flag=()
|
||||
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
|
||||
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
|
||||
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
|
||||
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c "
|
||||
import sys, json, os
|
||||
tn = os.environ.get('TARGET_NAME', '')
|
||||
tsa = os.environ.get('TARGET_SAN', '')
|
||||
tws = os.environ.get('TARGET_WS', '')
|
||||
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
|
||||
try:
|
||||
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
|
||||
# Client-side filter in case an older herdr ignores --workspace.
|
||||
if tws:
|
||||
panes = [p for p in panes if p.get('workspace_id') == tws]
|
||||
# ISSUE-2: exact match only.
|
||||
# ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback
|
||||
for key in ('label', 'name', 'agent'):
|
||||
for p in panes:
|
||||
v = p.get(key)
|
||||
@@ -367,6 +373,27 @@ try:
|
||||
if pid:
|
||||
print(pid)
|
||||
sys.exit(0)
|
||||
agent_kind = None
|
||||
yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml')
|
||||
if os.path.isfile(yaml_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path) as f:
|
||||
ydata = yaml.safe_load(f) or {}
|
||||
for s in ydata.get('herdr_sessions', []):
|
||||
if s.get('name') == tn or s.get('name') == tsa:
|
||||
agent_kind = s.get('pane', {}).get('cmd') or ''
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'warning: yaml load failed: {e}\n')
|
||||
if agent_kind:
|
||||
matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')]
|
||||
if len(matching) == 1:
|
||||
print(matching[0])
|
||||
sys.exit(0)
|
||||
elif len(matching) > 1:
|
||||
# R-1: Ambiguous resolution with multiple same-kind panes — fail closed
|
||||
sys.exit(1)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
@@ -731,13 +758,13 @@ except Exception:
|
||||
else
|
||||
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
|
||||
fi
|
||||
# Fatal CLI errors abort immediately. agent_not_ready is herdr's
|
||||
# documented "process is up, blocked on a dialog" status — continue
|
||||
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
|
||||
# startup" as success: herdr returns that for a dead process too.
|
||||
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
|
||||
break
|
||||
fi
|
||||
# Fatal CLI errors abort immediately. agent_not_ready is the
|
||||
# documented "process is up, blocked on a dialog" status — continue
|
||||
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
|
||||
# startup" as success: herdr returns that for a dead process too.
|
||||
if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
|
||||
success=1
|
||||
break
|
||||
@@ -1793,41 +1820,117 @@ start_watchdog() {
|
||||
|
||||
# wait_for_tui_ready <session_name> <agent>
|
||||
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
||||
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
|
||||
wait_for_tui_ready() {
|
||||
local sess="$1" agent="$2"
|
||||
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
|
||||
local tokens="${MAM_READY_TOKENS:-}"
|
||||
if [ -z "$tokens" ]; then
|
||||
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
||||
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
||||
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
||||
local prompt="${MAM_INPUT_PROMPT:-}"
|
||||
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
||||
local modal_tokens="${MAM_MODAL_TOKENS:-}"
|
||||
|
||||
# Self-contained resolution: fetch facts in 1 call if not already in environment
|
||||
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
|
||||
local _facts=""
|
||||
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
||||
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||
[ -n "$tokens" ] && eval "tokens=$tokens"
|
||||
if [ -n "$_facts" ]; then
|
||||
eval "$_facts"
|
||||
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
||||
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
||||
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
||||
prompt="${MAM_INPUT_PROMPT:-}"
|
||||
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
||||
modal_tokens="${MAM_MODAL_TOKENS:-}"
|
||||
fi
|
||||
fi
|
||||
if [ -z "$tokens" ]; then
|
||||
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
|
||||
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
|
||||
strong_tokens="$MAM_READY_TOKENS"
|
||||
fi
|
||||
|
||||
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
|
||||
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
||||
return 1
|
||||
fi
|
||||
local i
|
||||
|
||||
local empty_streak=0
|
||||
local empty_giveup="${MAM_READY_EMPTY_GIVEUP:-5}"
|
||||
local modal_pat="$_MAM_MODAL_TOKENS"
|
||||
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
||||
|
||||
local i content tail_lines
|
||||
for i in {1..30}; do
|
||||
if [ "$agent" = "claude" ]; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
fi
|
||||
if _pane_dialog_open "$sess"; then
|
||||
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter || true
|
||||
sleep 1
|
||||
# 1. Single capture per iteration (T-3)
|
||||
content=$(_pane_capture "$sess")
|
||||
|
||||
# Consecutive blank capture check (C-6)
|
||||
if [ -z "$content" ]; then
|
||||
empty_streak=$((empty_streak + 1))
|
||||
if [ "$empty_streak" -ge "$empty_giveup" ]; then
|
||||
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
|
||||
return 2
|
||||
fi
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
local content
|
||||
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
||||
if [ -n "$content" ]; then
|
||||
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
|
||||
echo "✅ $agent TUI detected ready."
|
||||
return 0
|
||||
empty_streak=0
|
||||
|
||||
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
|
||||
# handle it and do NOT declare ready on this iteration.
|
||||
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
||||
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
|
||||
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
|
||||
local s_matched=false
|
||||
local w_matched=false
|
||||
local c_matched=false
|
||||
|
||||
# S-layer: Strong ready token match
|
||||
if [ -n "$strong_tokens" ]; then
|
||||
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
|
||||
s_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# S-layer: Placeholder match (must be >= 8 characters)
|
||||
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
|
||||
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
|
||||
s_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# W-layer: Weak ready token match
|
||||
if [ -n "$weak_tokens" ]; then
|
||||
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
|
||||
w_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
|
||||
if [ -n "$prompt" ]; then
|
||||
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
|
||||
c_matched=true
|
||||
fi
|
||||
fi
|
||||
if [ -n "$rule_pattern" ]; then
|
||||
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
|
||||
c_matched=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Decision: S OR (W AND C)
|
||||
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
|
||||
echo "✅ $agent TUI detected ready."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Startup dialog & hint resolution if not ready
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
sleep 1
|
||||
done
|
||||
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
||||
@@ -1920,11 +2023,14 @@ _pane_quiescent() {
|
||||
}
|
||||
|
||||
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
|
||||
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
|
||||
# permission / OAuth / list-selection), checked in the bottom 8 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_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS"
|
||||
local extra="${MAM_MODAL_TOKENS:-}"
|
||||
local pat="$_MAM_MODAL_TOKENS"
|
||||
[ -n "$extra" ] && pat="${pat}|${extra}"
|
||||
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
|
||||
}
|
||||
|
||||
# send_keys_safe <sess> <text> [job_id]
|
||||
@@ -1939,6 +2045,23 @@ _pane_dialog_open() {
|
||||
send_keys_safe() {
|
||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||
local pre_submit deadline try
|
||||
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
|
||||
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN
|
||||
local MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
|
||||
|
||||
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
|
||||
local _sks_agent=""
|
||||
case "$sess" in
|
||||
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
|
||||
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
|
||||
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
|
||||
*-cline|*-cline-[0-9]*|cline|cline-[0-9]*) _sks_agent="cline" ;;
|
||||
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
|
||||
esac
|
||||
if [ -n "$_sks_agent" ]; then
|
||||
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local _q_rc=0
|
||||
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
||||
|
||||
Reference in New Issue
Block a user