feat(mux): implement onboarding prompt initialization, 4-tier UUID validation, and TUI 1:1 cross-matching

This commit is contained in:
2026-07-23 09:41:16 +09:00
parent c65b194d88
commit 15ffc8f6bb
9 changed files with 757 additions and 174 deletions
@@ -8,6 +8,7 @@
#
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
# multi-agent-mux-status 스킬이 이걸 재사용.
# 어떤 분기로도 디스크/DB 쓰기가 발생하지 않음을 보장함.
#
# 출력 (JSON): {timestamp, yaml_path, herdr_sessions_alive, herdr_confirmed, drifts, actions}
#
@@ -39,7 +40,15 @@ while [ $# -gt 0 ]; do
--subscribe) SUBSCRIBE=1; shift ;;
--timeout) SUB_TIMEOUT="$2"; shift 2 ;;
--idle-timeout) SUB_IDLE_TIMEOUT="$2"; shift 2 ;;
-h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run] [--subscribe [--timeout N] [--idle-timeout N]]"; exit 0 ;;
-h|--help)
cat <<EOF
Usage: $0 [--once] [--emit-diff] [--dry-run] [--subscribe [--timeout N] [--idle-timeout N]]
Options:
--dry-run Runs in read-only mode, guaranteeing no database or file writes are performed.
EOF
exit 0
;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
@@ -306,6 +315,8 @@ import os, json, glob, subprocess, time, sqlite3
from datetime import datetime, timezone
import yaml
exec(os.environ['MAM_VERIFY_PY'])
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
@@ -381,6 +392,31 @@ def pane_meta(session, srv):
return None
def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False):
own_key = {
'claude': 'claude_session_id_own',
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'cline': 'cline_conversation_id_own'
}[agent]
s[own_key] = uuid
s['last_visible_status'] = 'pinned'
resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'),
'--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run']
res = subprocess.run(resume_cmd, capture_output=True, text=True)
if res.returncode == 0:
s['last_visible_status'] = 'resume_verified'
else:
s['last_visible_status'] = f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}"
id_name = 'session' if agent in ('claude', 'cline') else 'conversation'
if not degraded:
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: {id_name} id materialized: {uuid}"})
else:
drifts.append({'class': 'C-degraded', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport check unavailable, pinned via stage 1-3 only"})
actions.append(f"updated {id_name} id: {uuid}")
yaml_sessions = d.get('herdr_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
@@ -492,29 +528,29 @@ for s in d.get('herdr_sessions', []):
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
proj_key = cwd.replace('/', '-').replace('_', '-')
proj_dir = f"{claude_project_dir}/{proj_key}"
key = workspace_key(cwd)
iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None
proj_dir = f"{iso}/projects/{key}" if iso else f"{claude_project_dir}/{key}"
if not os.path.isdir(proj_dir):
continue
jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
if not jsonls:
continue
latest = jsonls[0]
if time.time() - os.path.getmtime(latest) > 300:
continue
try:
with open(latest) as f:
first = f.readline().strip()
if not first:
continue
sid = json.loads(first).get('sessionId')
if not sid:
continue
except Exception:
continue
s['claude_session_id_own'] = sid
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {sid}"})
actions.append(f"updated session id: {sid}")
valid_candidates = []
for latest in jsonls:
uuid = os.path.basename(latest)[:-6]
if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"):
valid_candidates.append(uuid)
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=True)
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
@@ -527,18 +563,28 @@ for s in d.get('herdr_sessions', []):
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cid = lc_data.get(cwd)
if cid and os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
s['agy_conversation_id_own'] = cid
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
actions.append(f"updated conversation id: {cid}")
except Exception:
pass
iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None
conv_dir = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations"
if not os.path.isdir(conv_dir):
continue
dbs = sorted(glob.glob(f"{conv_dir}/*.db"), key=os.path.getmtime, reverse=True)
valid_candidates = []
for db in dbs:
uuid = os.path.basename(db)[:-3]
if verify_session_uuid(cwd, 'agy', uuid, s, mode="discover"):
valid_candidates.append(uuid)
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=True)
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
@@ -551,19 +597,33 @@ for s in d.get('herdr_sessions', []):
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
hdb = f"{home}/.hermes/state.db"
if os.path.exists(hdb):
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (cwd,)).fetchone()
conn.close()
if r:
cid = r[0]
s['hermes_conversation_id_own'] = cid
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
actions.append(f"updated conversation id: {cid}")
except Exception:
pass
iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None
hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
continue
valid_candidates = []
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (cwd,)).fetchone()
conn.close()
if r:
uuid = r[0]
if verify_session_uuid(cwd, 'hermes', uuid, s, mode="discover"):
valid_candidates.append(uuid)
except Exception:
pass
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True)
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []):
@@ -576,29 +636,36 @@ for s in d.get('herdr_sessions', []):
cwd = (s.get('pane') or {}).get('cwd', '')
if not cwd:
continue
sessions_dir = f"{home}/.cline/data/sessions"
if os.path.isdir(sessions_dir):
candidates = []
for session_folder in glob.glob(f"{sessions_dir}/*"):
if os.path.isdir(session_folder):
folder_name = os.path.basename(session_folder)
json_file = f"{session_folder}/{folder_name}.json"
if os.path.exists(json_file):
candidates.append(json_file)
candidates.sort(key=os.path.getmtime, reverse=True)
for j in candidates:
try:
with open(j) as f:
sdata = json.load(f)
if sdata.get('cwd') == cwd or sdata.get('workspace_root') == cwd:
cid = sdata.get('session_id')
if cid:
s['cline_conversation_id_own'] = cid
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {cid}"})
actions.append(f"updated session id: {cid}")
break
except Exception:
pass
iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None
sessions_dir = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions"
if not os.path.isdir(sessions_dir):
continue
candidates = []
for session_folder in glob.glob(f"{sessions_dir}/*"):
if os.path.isdir(session_folder):
folder_name = os.path.basename(session_folder)
json_file = f"{session_folder}/{folder_name}.json"
if os.path.exists(json_file):
candidates.append(json_file)
candidates.sort(key=os.path.getmtime, reverse=True)
valid_candidates = []
for j in candidates:
uuid = os.path.basename(j)[:-5]
if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"):
valid_candidates.append(uuid)
break
if len(valid_candidates) == 1:
uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"']
rc = subprocess.run(cmd).returncode
if rc == 0:
_pin_and_verify_resume(s, 'cline', cwd, uuid, degraded=False)
elif rc == 1:
drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"})
else:
_pin_and_verify_resume(s, 'cline', cwd, uuid, degraded=True)
# === drift D: stale UUID (cache 의 artifact 가 사라짐) — 보고만, 변경 없음 ===
ai = d.get('agent_identities', {}) or {}
@@ -653,7 +720,7 @@ if not actions:
PYEOF
if [ "$DRY_RUN" = "1" ]; then
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML"
else
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
fi