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
+196 -94
View File
@@ -874,6 +874,171 @@ PYEOF
}
# ---------------------------------------------------------------------------
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
# ---------------------------------------------------------------------------
VERIFY_SESSION_PYTHON='
def workspace_key(path):
return path.replace("/", "-").replace("_", "-")
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3
home = home_dir or os.environ.get("HOME_DIR", "")
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {}
epoch = row.get("herdr_session_epoch", 0)
cwd = row.get("pane", {}).get("cwd", "") or ws
iso = row.get("isolation", {}).get("root") if isinstance(row.get("isolation"), dict) else None
if workspace_key(cwd) != workspace_key(ws):
return False
if agent == "claude":
base = f"{iso}/projects" if iso else c_dir
key = workspace_key(ws)
path = f"{base}/{key}/{uuid}.jsonl"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
with open(path) as f:
first = f.readline().strip()
if not first:
return False
payload = json.loads(first)
if payload.get("sessionId") != uuid:
return False
if payload.get("cwd") and payload.get("cwd") != cwd:
return False
except Exception:
return False
elif agent == "agy":
base = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations"
path = f"{base}/{uuid}.db"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
if mode == "discover":
lc = f"{iso}/.gemini/antigravity-cli/cache/last_conversations.json" if iso else 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)
if lc_data.get(cwd) != uuid:
return False
except Exception:
return False
try:
conn = sqlite3.connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
return False
except Exception:
return False
elif agent == "hermes":
hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
return False
if epoch and os.path.getmtime(hdb) < epoch:
return False
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
return False
except Exception:
return False
elif agent == "cline":
base = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions"
path = f"{base}/{uuid}/{uuid}.json"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
with open(path) as f:
sdata = json.load(f)
if sdata.get("session_id") != uuid:
return False
except Exception:
return False
return True
'
verify_session_uuid() {
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json
exec(os.environ['MAM_VERIFY_PY'])
ws = os.environ['WS_ABS']
agent = os.environ['AGENT']
uuid = os.environ['UUID']
row_str = os.environ.get('ROW_JSON', '')
row = json.loads(row_str) if row_str else {}
mode = os.environ.get('MODE', 'discover')
if verify_session_uuid(ws, agent, uuid, row, mode=mode):
sys.exit(0)
sys.exit(1)
PYEOF
}
# verify_tui_viewport <session_name> <agent> <workspace>
#
# Viewport matching verification:
# | Return | Meaning | Action Required |
# |---|---|---|
# | 0 | Viewport matches workspace | Pin UUID, last_visible_status='pinned', drift class C |
# | 1 | Viewport mismatch | Do not pin, log C-warn, last_visible_status unchanged |
# | 2 | Viewport check unavailable | Pin UUID via stage 1-3 only, log C-degraded |
#
# Returns:
# 0 = verified match
# 1 = mismatch
# 2 = check not possible (capture failed, session gone)
verify_tui_viewport() {
local session_name="$1" agent="$2" workspace="$3"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
local base; base="$(basename "$abs")"
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
return 2
fi
local pane_content
pane_content=$(_pane_capture "$session_name" 2>/dev/null || true)
if [ -z "$pane_content" ]; then
return 2
fi
case "$agent" in
claude)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
agy|hermes|cline)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
*)
return 2
;;
esac
}
# ---------------------------------------------------------------------------
# find_workspace_uuid <workspace> <agent>
#
@@ -890,7 +1055,7 @@ PYEOF
find_workspace_uuid() {
local workspace="$1" agent="$2" session_name="${3:-}"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json, glob, sqlite3
ws = os.environ['WS_ABS']
@@ -899,49 +1064,15 @@ home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
target = os.environ.get('TARGET_SESSION', '')
exec(os.environ['MAM_VERIFY_PY'])
OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'}
def iso_root_of(s):
iso = s.get('isolation')
return iso.get('root') if isinstance(iso, dict) else None
def jsonl_exists(uuid, iso=None):
base = f"{iso}/projects" if iso else claude_project_dir
key = ws.replace('/', '-').replace('_', '-')
return os.path.exists(f"{base}/{key}/{uuid}.jsonl")
def db_exists(uuid, iso=None):
base = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations"
return os.path.exists(f"{base}/{uuid}.db")
def hermes_exists(uuid, iso=None):
hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
return False
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
return r is not None
except Exception:
return False
def cline_exists(uuid, iso=None):
base = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions"
return os.path.exists(f"{base}/{uuid}/{uuid}.json")
def own_exists(a, uuid, iso=None):
return {'claude': jsonl_exists, 'agy': db_exists,
'hermes': hermes_exists, 'cline': cline_exists}[a](uuid, iso)
# Ingest the merged state dictionary from stdin pipeline
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
@@ -958,51 +1089,38 @@ for s_item in d.get('herdr_sessions', []):
if val:
running_ids.add(val)
def emit(u):
if u in running_ids:
return
print(u)
raise SystemExit(0)
# Fetch all sessions matching this workspace
sessions = []
for s in d.get('herdr_sessions', []):
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
sessions.append(s)
# T5: target-session mode — resolve strictly within the named row's scope.
# An isolated row's conversation lives ONLY in its isolation root (which holds
# at most one conversation), so we never fall through to the global tiers —
# those would guess another role's conversation (the C3 mtime bug).
if target:
for s in sessions:
if s.get('name') != target:
continue
iso = iso_root_of(s)
cand = s.get(OWN_KEY.get(agent, ''), None)
if cand and own_exists(agent, cand, iso):
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if iso:
key = ws.replace('/', '-').replace('_', '-')
key = workspace_key(ws)
if agent == 'claude':
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
sid = None
try:
with open(j) as f:
first = f.readline().strip()
if first:
sid = json.loads(first).get('sessionId')
except Exception:
sid = None
cand = sid or os.path.basename(j)[:-6]
if cand:
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'agy':
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
emit(os.path.basename(j)[:-3])
cand = os.path.basename(j)[:-3]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'hermes':
hdb = f"{iso}/.hermes/state.db"
if os.path.exists(hdb):
@@ -1011,55 +1129,46 @@ if target:
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
conn.close()
if r:
emit(r[0])
cand = r[0]
if verify_session_uuid(ws, agent, cand, s):
emit(cand)
except Exception:
pass
elif agent == 'cline':
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
fn = os.path.basename(folder)
if os.path.exists(f"{folder}/{fn}.json"):
emit(fn)
# isolated row: nothing found inside its exclusive root -> no guess
if verify_session_uuid(ws, agent, fn, s):
emit(fn)
print('')
raise SystemExit(0)
# target row absent or legacy (non-isolated): fall through to legacy tiers
for s in sessions:
name = s.get('name', '')
iso = iso_root_of(s)
if agent == 'claude' and name.endswith('-creator-claude'):
cand = s.get('claude_session_id_own')
if cand and jsonl_exists(cand, iso):
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'agy' and name.endswith('-creator-agy'):
cand = s.get('agy_conversation_id_own')
if cand and db_exists(cand, iso):
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'hermes' and name.endswith('-creator-hermes'):
cand = s.get('hermes_conversation_id_own')
if cand and hermes_exists(cand, iso):
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'cline' and name.endswith('-creator-cline'):
cand = s.get('cline_conversation_id_own')
if cand and cline_exists(cand, iso):
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
# 2) disk scan scoped to THIS workspace
if agent == 'claude':
key = ws.replace('/', '-').replace('_', '-')
key = workspace_key(ws)
proj = f"{claude_project_dir}/{key}"
if os.path.isdir(proj):
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
sid = None
try:
with open(j) as f:
first = f.readline().strip()
if first:
sid = json.loads(first).get('sessionId')
except Exception:
sid = None
cand = sid or os.path.basename(j)[:-6]
if cand and jsonl_exists(cand):
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'agy':
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
@@ -1069,7 +1178,7 @@ elif agent == 'agy':
cand = json.load(open(lc)).get(ws)
except Exception:
cand = None
if cand and db_exists(cand):
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'hermes':
hdb = f"{home}/.hermes/state.db"
@@ -1083,7 +1192,7 @@ elif agent == 'hermes':
cand = r[0]
except Exception:
cand = None
if cand:
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'cline':
sessions_dir = f"{home}/.cline/data/sessions"
@@ -1097,17 +1206,10 @@ elif agent == 'cline':
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') == ws or sdata.get('workspace_root') == ws:
sid = sdata.get('session_id')
if sid:
emit(sid)
except Exception:
pass
cand = os.path.basename(j)[:-5]
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
ai = {}
try:
if os.path.exists(db_path):
@@ -1127,19 +1229,19 @@ ai_agent = ai.get(agent) or {}
if ai_agent.get('project_cwd') == ws:
if agent == 'claude':
cand = ai_agent.get('session_id')
if cand and jsonl_exists(cand):
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'agy':
cand = ai.get('conversation_id')
if cand and db_exists(cand):
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'hermes':
cand = ai_agent.get('session_id') or ai.get('conversation_id')
if cand and hermes_exists(cand):
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'cline':
cand = ai_agent.get('session_id') or ai.get('conversation_id')
if cand and cline_exists(cand):
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
print('')
@@ -35,6 +35,7 @@ Options:
--herdr-server NAME specify isolated herdr server name
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
--onboard automatically submit a project alignment/orientation job to the new agent
--no-onboard disable automatic onboarding job submission
--no-isolate disable state isolation (shares global configuration/history)
[default: isolated mode is always active]
-h, --help this help
@@ -49,7 +50,7 @@ USE_WRAPPER=0
DRY_RUN=0
HERDR_SERVER_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=0
ONBOARD=1
ISOLATE=1
while [ $# -gt 0 ]; do
@@ -63,6 +64,7 @@ while [ $# -gt 0 ]; do
--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--no-onboard) ONBOARD=0; shift ;;
--isolate) ISOLATE=1; shift ;; # legacy compatibility
--no-isolate) ISOLATE=0; shift ;;
-h|--help) usage; exit 0 ;;
@@ -239,7 +241,7 @@ START_CMD="HERDR_SERVER_NAME=${HERDR_SERVER_NAME:-default} herdr new-session -d
# If --onboard is specified, automatically build the onboarding prompt
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. To align yourself with the project context, perform the following tasks:
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules.
2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository.
3. Read .mam/agent-sessions.yaml to see other running agent sessions, their roles, and confirm your own assigned role: $ROLE.
@@ -348,7 +350,7 @@ if agent == 'claude':
'version': '(unknown — read from TUI)',
}
entry['claude_session_id_own'] = None
entry['last_visible_status'] = "TUI started; awaiting first user message"
entry['last_visible_status'] = "unverified"
elif agent == 'agy':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
@@ -360,17 +362,17 @@ elif agent == 'agy':
'endpoint': 'https://stitch.googleapis.com/mcp'
}
]
entry['last_visible_status'] = "TUI started; awaiting first user message"
entry['last_visible_status'] = "unverified"
elif agent == 'hermes':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['hermes_conversation_id_own'] = None
entry['last_visible_status'] = "TUI started; awaiting first user message"
entry['last_visible_status'] = "unverified"
elif agent == 'cline':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['cline_conversation_id_own'] = None
entry['last_visible_status'] = "TUI started; awaiting first user message"
entry['last_visible_status'] = "unverified"
sessions.append(entry)
@@ -407,6 +409,8 @@ Task: $SUBMIT_JOB_PROMPT"
fi
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected"
# Trigger immediate priority reconcile cycle asynchronously
(bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE")
echo "watchdog PID: $WD_PID"
fi
@@ -112,7 +112,8 @@ Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E —
## Drift classes (what the script handles)
### Status Enum
The `status` and `last_visible_status` fields MUST be one of the following exact strings: `running`, `stopped`, `terminated`, `archived`.
The `status` field MUST be one of the following exact strings: `running`, `stopped`, `terminated`, `archived`.
The `last_visible_status` is a free-form human-readable status string (e.g. verification-cycle states: `unverified`, `pinned`, `resume_verified`, or a failure detail string) and is NOT constrained to this enum.
Any unstructured comments or reasons for the status change should be placed in `last_visible_note` or `termination_mode`.
### A. herdr dead, YAML says running → auto-terminate
@@ -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
@@ -6,7 +6,11 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() {
cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> --session <name>
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> --session <name> [--dry-run]
Options:
--dry-run Simulates resume flow (resolves binary, environment, isolation) without writing
any updates to YAML or DB. Safe to execute inside active write transactions.
EOF
}
@@ -14,11 +18,14 @@ WORKSPACE=""
AGENT=""
SESSION_NAME=""
DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--session) SESSION_NAME="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
@@ -42,6 +49,10 @@ export HERDR_SERVER_NAME
# 2. If herdr is alive, print warning or attach.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
if [ "${DRY_RUN:-0}" = "1" ]; then
echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"
exit 0
fi
echo "herdr '$SESSION_NAME' already running."
# Just update YAML to make sure it's set to running
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
@@ -111,6 +122,7 @@ case "$AGENT" in
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
esac
# Prepend env prefix and append command args (T4)
@@ -121,6 +133,30 @@ if [ -n "$ISO_ARGS" ]; then
CMD_FULL="$CMD_FULL $ISO_ARGS"
fi
# Validate isolation root if it was configured
if [ -n "$ISO_ROOT" ] && [ ! -d "$ISO_ROOT" ]; then
echo "ERROR: Isolation root directory does not exist: $ISO_ROOT" >&2
exit 1
fi
# Validate binary exists and is executable
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
if [ ! -x "$RESOLVED_BIN" ]; then
echo "ERROR: Agent binary is not executable: $RESOLVED_BIN" >&2
exit 1
fi
else
if ! command -v "$RESOLVED_BIN" >/dev/null 2>&1; then
echo "ERROR: Agent binary not found in PATH: $RESOLVED_BIN" >&2
exit 1
fi
fi
if [ "${DRY_RUN:-0}" = "1" ]; then
echo "[dry-run] would spawn: $CMD_FULL"
exit 0
fi
# 4. Spawn new agent session (delegates to herdr translation shim)
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
if [ "$AGENT" = "claude" ]; then
@@ -50,7 +50,7 @@ fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 새 herdr pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
PANE_PID="${PANE_PID:-}"
CHILD_PID=0
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then