#!/usr/bin/env bash # status.sh — multi-agent-mux-status 의 부속 스크립트 (READ-ONLY) # 한 번 호출로 현재 agent 세션 상태표를 출력. 부수효과 없음. # reconcile.sh --dry-run 을 재사용해 drift 를 계산하고 (P1-E), YAML/디스크에서 # 보강한 표를 그린다. YAML 을 절대 수정하지 않는다. # # Usage: bash status.sh [--json] set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh" JSON=0 [ "${1:-}" = "--json" ] && JSON=1 [ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found. Run multi-agent-mux-create first." >&2; exit 1; } # read-only drift snapshot — reconcile.sh --dry-run (no side effects) DRIFT_JSON="$(bash "$RECONCILE" --once --emit-diff --dry-run)" # Project root (parent of .agents/) holds the multi-agent-mux-delegate-job .mam registry. # Resolved relative to this script — no hardcoded absolute path (review item 6). PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)" if [ "$JSON" = "1" ]; then # D8: --json historically only carried reconcile.sh's drift subset (timestamp/ # yaml_path/herdr_sessions_alive/herdr_confirmed/drifts/actions), not the enriched # per-row fields (RESUME/JOB_ID/JOB_STATUS/CMD/attach_command/pane.cwd/...) that # only this script's text-mode block computed. Fixed additively below via a # 'sessions_detail' key — every existing key is passed through untouched, so # any consumer of the pre-fix schema keeps working unmodified. MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF' import os, json, glob home = os.environ['HOME_DIR'] claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") drift = json.loads(os.environ['DRIFT_JSON']) try: d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) except Exception: d = {} alive = set(drift.get('herdr_sessions_alive', [])) drift_by_name = {} for dr in drift.get('drifts', []): drift_by_name.setdefault(dr['name'], []).append(dr['class']) def resume_on_disk(s): # workspace-SCOPED check only — per-row own id, never a global identity (P0-C) name = s.get('name', '') cwd = (s.get('pane') or {}).get('cwd', '') agent = None for a in ('claude', 'agy', 'hermes', 'cline', 'grok'): if any(name.endswith(f'-{r}-{a}') for r in ('creator', 'planner', 'reviewer')) or name.endswith(f'-{a}'): agent = a break if not agent: for a in ('claude', 'agy', 'hermes', 'cline', 'grok'): if f"-{a}" in name or f"_{a}" in name: agent = a break if agent == 'claude': u = s.get('claude_session_id_own') if u: key = cwd.replace('/', '-').replace('_', '-') return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING' key = cwd.replace('/', '-').replace('_', '-') return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no' if agent == 'agy': u = s.get('agy_conversation_id_own') if u: return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING' return 'no' if agent == 'hermes': u = s.get('hermes_conversation_id_own') if u: hdb = f"{home}/.hermes/state.db" if not os.path.exists(hdb): return 'MISSING' try: import sqlite3 conn = sqlite3.connect(hdb) r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (u,)).fetchone() conn.close() return 'yes' if r else 'MISSING' except Exception: return 'MISSING' return 'no' if agent == 'cline': u = s.get('cline_conversation_id_own') if u: return 'yes' if os.path.exists(f"{home}/.cline/data/sessions/{u}/{u}.json") else 'MISSING' return 'no' if agent == 'grok': u = s.get('grok_session_id_own') if u: import urllib.parse ws_slug = urllib.parse.quote(cwd, safe='') return 'yes' if os.path.exists(f"{home}/.grok/sessions/{ws_slug}/{u}/chat_history.jsonl") else 'MISSING' return 'no' return '?' def get_job_status(s): jid = s.get('delegate_job_id') if not jid: return ('-', '-') project_root = os.environ.get('PROJECT_ROOT', '.') candidates = [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), ] for path in candidates: if os.path.exists(path): try: with open(path) as jf: job_data = json.load(jf) return (jid, job_data.get('status', 'unknown')) except Exception: pass return (jid, 'unknown') def _slug(path): if not path: return '' import re a = os.path.abspath(path) parent = os.path.basename(os.path.dirname(a)) or 'workspace' work = os.path.basename(a) or 'root' if parent in ('/', '.'): parent = 'workspace' if work in ('/', '.'): work = 'root' s = f'{parent}-{work}'.lower().replace('_', '-') return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-') sessions_detail = [] from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize def is_alive(name, server): return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive for s in d.get('herdr_sessions', []): name = s.get('name', '?') # herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다. server = s.get('herdr_session') or s.get('herdr_server') or 'default' jid, jstatus = get_job_status(s) pane = s.get('pane') or {} wslabel = s.get('herdr_workspace') or _slug(pane.get('cwd', '')) or None sessions_detail.append({ # Fields named/typed to match the reviewed D8 contract # (.mam/jobs/40bdce88/claude-reports/report-final.md §3.1) exactly — # mam_core maps this straight onto its Session/Pane/Drift models. 'name': name, 'server': server, 'herdr_workspace': wslabel, 'status': s.get('status', '?'), 'herdr_alive': is_alive(name, server), 'cmd': pane.get('cmd'), 'role': s.get('role'), 'resume_state': resume_on_disk(s), 'job_id': jid, 'job_status': jstatus, 'pane_cwd': pane.get('cwd'), 'attach_command': s.get('attach_command'), 'drift_classes': drift_by_name.get(name, []), # Additive beyond the D8 example — needed by the M1 Detail Pane # (pane pid / full launch command / start command / last banner text). 'pane_pid': pane.get('pid'), 'cmd_full': pane.get('cmd_full'), 'start_command': s.get('start_command'), 'last_visible_status': s.get('last_visible_status'), }) drift['sessions_detail'] = sessions_detail print(json.dumps(drift, ensure_ascii=False)) PYEOF exit 0 fi MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF' import os, sys, json, glob home = os.environ['HOME_DIR'] claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") drift = json.loads(os.environ['DRIFT_JSON']) try: d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) except Exception: d = {} alive = set(drift.get('herdr_sessions_alive', [])) drift_by_name = {} for dr in drift.get('drifts', []): drift_by_name.setdefault(dr['name'], []).append(dr['class']) def resume_on_disk(s): # workspace-SCOPED check only — per-row own id, never a global identity (P0-C) name = s.get('name', '') cwd = (s.get('pane') or {}).get('cwd', '') if name.endswith('-creator-claude'): u = s.get('claude_session_id_own') if u: key = cwd.replace('/', '-').replace('_', '-') return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING' key = cwd.replace('/', '-').replace('_', '-') return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no' if name.endswith('-creator-agy'): u = s.get('agy_conversation_id_own') if u: return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING' return 'no' return '?' def get_job_status(s): jid = s.get('delegate_job_id') if not jid: return ('-', '-') project_root = os.environ.get('PROJECT_ROOT', '.') # Candidate locations (review item 6: project-root-relative, no hardcoded abs paths): # 1) cwd-relative registry 2) project-root registry 3) project-root audit log candidates = [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), ] for path in candidates: if os.path.exists(path): try: with open(path) as jf: job_data = json.load(jf) return (jid, job_data.get('status', 'unknown')) except Exception: pass return (jid, 'unknown') def _slug(path): if not path: return '' import re a = os.path.abspath(path) parent = os.path.basename(os.path.dirname(a)) or 'workspace' work = os.path.basename(a) or 'root' if parent in ('/', '.'): parent = 'workspace' if work in ('/', '.'): work = 'root' s = f'{parent}-{work}'.lower().replace('_', '-') return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-') from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize sessions = d.get('herdr_sessions', []) print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})") print("=" * 150) print(f"{'NAME':<44} {'SOCKET':<12} {'WORKSPACE':<14} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT") print("-" * 150) if not sessions: print("(no sessions registered)") def is_alive(name, server): return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive for s in sessions: name = s.get('name', '?') # herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다. server = s.get('herdr_session') or s.get('herdr_server') or 'default' wslabel = s.get('herdr_workspace') or _slug((s.get('pane') or {}).get('cwd', '')) or '-' status = s.get('status', '?') herdr = 'alive' if is_alive(name, server) else 'dead' cmd = (s.get('pane') or {}).get('cmd', '?') res = resume_on_disk(s) jid, jstatus = get_job_status(s) drs = ','.join(drift_by_name.get(name, [])) or '-' print(f"{name:<44} {server:<12} {wslabel:<14} {status:<10} {herdr:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}") # drifts not tied to a registered row (e.g. class B unregistered, class D cache) known = {s.get('name') for s in sessions} extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known] if extra: print("-" * 136) for dr in extra: print(f" [{dr['class']}] {dr['msg']}") print("=" * 136) print(f"alive herdr: {sorted(alive)}") PYEOF