diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index f083d4c..93715aa 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -311,6 +311,10 @@ def _validate(d): raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}") if not isinstance(s.get('pane'), dict): raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane") + iso = s.get('isolation') + if iso is not None: + if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'): + raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root") def get_terminal_set(d): return {s.get('name'): s.get('status') for s in d.get('tmux_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')} @@ -382,6 +386,19 @@ try: if name in old_roles and s.get('role') != old_roles[name]: raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}") + # ID Uniqueness Check (T2) + running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own'] + id_to_session = {} + for s in d.get('tmux_sessions', []): + if s.get('status') == 'running': + s_name = s.get('name') + for k in running_keys: + v = s.get(k) + if v: + if v in id_to_session and id_to_session[v] != s_name: + raise SystemExit(f"VALIDATE: Duplicate running conversation ID {v!r} detected between {id_to_session[v]!r} and {s_name!r}") + id_to_session[v] = s_name + _validate(d) # Separate globals and sessions for normalization @@ -463,9 +480,9 @@ PYEOF # Prints the UUID on stdout (empty line if none). Always exits 0. # --------------------------------------------------------------------------- find_workspace_uuid() { - local workspace="$1" agent="$2" + local workspace="$1" agent="$2" session_name="${3:-}" local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" - WS_ABS="$abs" AGENT="$agent" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' + WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, json, glob, sqlite3 import yaml @@ -475,18 +492,33 @@ home = os.environ['HOME_DIR'] yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") +target = os.environ.get('TARGET_SESSION', '') -def jsonl_exists(uuid): +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): + # T5: per-row isolation root (all-L2). None => legacy global state dirs. + iso = s.get('isolation') + return iso.get('root') if isinstance(iso, dict) else None + + +# exists checks take an optional isolation root; path templates per lever +# (Phase 0 measured — note cline's isolated layout is /sessions/, NOT data/sessions/). +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"{claude_project_dir}/{key}/{uuid}.jsonl") + return os.path.exists(f"{base}/{key}/{uuid}.jsonl") -def db_exists(uuid): - return os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{uuid}.db") +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): - hdb = f"{home}/.hermes/state.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: @@ -498,11 +530,52 @@ def hermes_exists(uuid): return False -def cline_exists(uuid): - return os.path.exists(f"{home}/.cline/data/sessions/{uuid}/{uuid}.json") +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) + + +running_ids = set() +try: + if os.path.exists(db_path): + conn = sqlite3.connect(db_path, timeout=60.0) + try: + cursor = conn.execute("SELECT data FROM sessions WHERE status='running'") + for r_row in cursor.fetchall(): + s_data = json.loads(r_row[0]) + # the target row's own ids are legitimately "claimed by itself" — don't filter them + if target and s_data.get('name') == target: + continue + for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']: + val = s_data.get(k) + if val: + running_ids.add(val) + except sqlite3.OperationalError: + pass + conn.close() + if not running_ids and os.path.exists(yaml_path): + with open(yaml_path) as f: + d_state = yaml.safe_load(f) or {} + for s_item in d_state.get('tmux_sessions', []): + if s_item.get('status') == 'running': + if target and s_item.get('name') == target: + continue + for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']: + val = s_item.get(k) + if val: + running_ids.add(val) +except Exception: + pass def emit(u): + if u in running_ids: + return print(u) raise SystemExit(0) @@ -537,23 +610,75 @@ try: except Exception: pass +# 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): + emit(cand) + if iso: + key = ws.replace('/', '-').replace('_', '-') + 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: + 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]) + elif agent == 'hermes': + hdb = f"{iso}/.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", (ws,)).fetchone() + conn.close() + if r: + emit(r[0]) + 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 + 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): + if cand and jsonl_exists(cand, iso): emit(cand) if agent == 'agy' and name.endswith('-creator-agy'): cand = s.get('agy_conversation_id_own') - if cand and db_exists(cand): + if cand and db_exists(cand, iso): emit(cand) if agent == 'hermes' and name.endswith('-creator-hermes'): cand = s.get('hermes_conversation_id_own') - if cand and hermes_exists(cand): + if cand and hermes_exists(cand, iso): emit(cand) if agent == 'cline' and name.endswith('-creator-cline'): cand = s.get('cline_conversation_id_own') - if cand and cline_exists(cand): + if cand and cline_exists(cand, iso): emit(cand) # 2) disk scan scoped to THIS workspace @@ -659,18 +784,101 @@ PYEOF } # --------------------------------------------------------------------------- -# capture_conversation_id +# capture_conversation_id [session_name] # # Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation # id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line # if none). find_workspace_uuid is already a workspace-scoped, 3-tier, race-free # resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache), # so recording its result into the row before kill guarantees tier-1 on the next -# resume. Always exits 0. +# resume. Pass session_name to scope resolution to that row (required for +# isolated sessions — see isolation block / T5). Always exits 0. # --------------------------------------------------------------------------- capture_conversation_id() { - local agent="$1" workdir="$2" - find_workspace_uuid "$workdir" "$agent" + local agent="$1" workdir="$2" session_name="${3:-}" + find_workspace_uuid "$workdir" "$agent" "$session_name" +} + +# --------------------------------------------------------------------------- +# Session isolation (all-L2) — implementation_plan.session_isolation.md Rev.3 +# +# Every isolated session gets its own state home `.mam/agent_homes//`; +# auth/config files are SYMLINKED into it (never copied — token refresh must +# converge on the real files). Levers per agent (Phase 0 measured matrix): +# claude: CLAUDE_CONFIG_DIR= conv: /projects//.jsonl +# cline: --data-dir conv: /sessions//.json +# agy: HOME= conv: /.gemini/antigravity-cli/conversations/ +# hermes: HOME= conv: /.hermes/state.db +# +# provision_isolation — mkdir + seed; prints comma-joined seeded list +# isolation_lever — claude_config_dir|cline_data_dir|home +# isolation_env_prefix — "VAR= " spawn prefix ('' for cline) +# isolation_cmd_args — extra spawn CLI args ('' unless cline) +# --------------------------------------------------------------------------- +provision_isolation() { + local agent="$1" root="$2" seeded="" f base + mkdir -p "$root" + case "$agent" in + claude) + ln -sfn "$HOME/.claude/.credentials.json" "$root/.credentials.json"; seeded=".credentials.json" + if [ -e "$HOME/.claude/settings.json" ]; then ln -sfn "$HOME/.claude/settings.json" "$root/settings.json"; seeded="$seeded,settings.json"; fi + if [ -d "$HOME/.claude/plugins" ]; then ln -sfn "$HOME/.claude/plugins" "$root/plugins"; seeded="$seeded,plugins"; fi + ;; + cline) + # Phase 0 실측: --config 공유 지정만으론 auth 미공유 — settings/* + globalState.json 시딩 필수 + mkdir -p "$root/settings" + for f in "$HOME/.cline/data/settings/"*; do + [ -e "$f" ] || continue + base="$(basename "$f")" + ln -sfn "$f" "$root/settings/$base"; seeded="${seeded:+$seeded,}settings/$base" + done + if [ -e "$HOME/.cline/data/globalState.json" ]; then + ln -sfn "$HOME/.cline/data/globalState.json" "$root/globalState.json"; seeded="${seeded:+$seeded,}globalState.json" + fi + ;; + agy) + mkdir -p "$root/.gemini/antigravity-cli" + for f in oauth_creds.json google_accounts.json installation_id settings.json state.json; do + if [ -e "$HOME/.gemini/$f" ]; then ln -sfn "$HOME/.gemini/$f" "$root/.gemini/$f"; seeded="${seeded:+$seeded,}.gemini/$f"; fi + done + for f in antigravity-oauth-token installation_id settings.json; do + if [ -e "$HOME/.gemini/antigravity-cli/$f" ]; then ln -sfn "$HOME/.gemini/antigravity-cli/$f" "$root/.gemini/antigravity-cli/$f"; seeded="${seeded:+$seeded,}.gemini/antigravity-cli/$f"; fi + done + ;; + hermes) + mkdir -p "$root/.hermes" + for f in auth.json config.yaml .env; do + if [ -e "$HOME/.hermes/$f" ]; then ln -sfn "$HOME/.hermes/$f" "$root/.hermes/$f"; seeded="${seeded:+$seeded,}.hermes/$f"; fi + done + ;; + esac + printf '%s\n' "$seeded" +} + +isolation_lever() { + case "$1" in + claude) echo "claude_config_dir" ;; + cline) echo "cline_data_dir" ;; + agy|hermes) echo "home" ;; + *) echo "" ;; + esac +} + +isolation_env_prefix() { + local agent="$1" root="$2" + case "$agent" in + claude) printf 'CLAUDE_CONFIG_DIR=%q ' "$root" ;; + agy|hermes) printf 'HOME=%q ' "$root" ;; + *) : ;; + esac +} + +isolation_cmd_args() { + local agent="$1" root="$2" + case "$agent" in + cline) printf -- '--data-dir %q' "$root" ;; + *) : ;; + esac } # --------------------------------------------------------------------------- diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index cdcccf2..8186672 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -35,6 +35,8 @@ Options: --tmux-server NAME specify isolated tmux 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 + --isolate provision an isolated per-session state home (.mam/agent_homes/) + so same-CLI multi-role sessions never share a conversation id -h, --help this help EOF } @@ -48,6 +50,7 @@ DRY_RUN=0 TMUX_SERVER_OPT="" SUBMIT_JOB_PROMPT="" ONBOARD=0 +ISOLATE=0 while [ $# -gt 0 ]; do case "$1" in @@ -60,6 +63,7 @@ while [ $# -gt 0 ]; do --tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;; --submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;; --onboard) ONBOARD=1; shift ;; + --isolate) ISOLATE=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;; esac @@ -111,28 +115,58 @@ if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then exit 3 fi +# T3: 세션 격리 프로비저닝 (all-L2) — 격리 홈 생성 + auth/설정 심링크 시딩 +# (implementation_plan.session_isolation.md Rev.3 / Phase 0 실측 매트릭스 기준) +ISOLATION_UUID="" +ISOLATION_ROOT="" +ISOLATION_LEVER="" +ISOLATION_SEEDED="" +if [ "$ISOLATE" = "1" ]; then + command -v uuidgen >/dev/null || { echo "ERROR: uuidgen not found (required for --isolate)" >&2; exit 1; } + WORKSPACE_ABS="$(cd "$WORKSPACE" && pwd)" + ISOLATION_UUID="$(uuidgen)" + ISOLATION_ROOT="$WORKSPACE_ABS/.mam/agent_homes/$ISOLATION_UUID" + ISOLATION_LEVER="$(isolation_lever "$AGENT")" + if [ "$DRY_RUN" = "1" ]; then + echo "[dry-run] would provision isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT" + else + ISOLATION_SEEDED="$(provision_isolation "$AGENT" "$ISOLATION_ROOT")" + echo "isolation: lever=$ISOLATION_LEVER root=$ISOLATION_ROOT" + fi +fi + # tmux 세션 띄우기 LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}" WRAPPER="$LOCAL_BIN/$SESSION_NAME" +# cmd_full 결정 — T4: isolation 디스패치(env prefix / CLI args)를 시작 명령에 주입. +# 격리 미사용 시 기존 문자열과 byte-identical (V4 회귀 0). +ISO_ENV_PREFIX="" +ISO_CMD_ARGS="" +if [ -n "$ISOLATION_ROOT" ]; then + ISO_ENV_PREFIX="$(isolation_env_prefix "$AGENT" "$ISOLATION_ROOT")" + ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")" +fi +case "$AGENT" in + claude) CMD_FULL="${ISO_ENV_PREFIX}claude --dangerously-skip-permissions" ;; + agy) CMD_FULL="${ISO_ENV_PREFIX}agy --dangerously-skip-permissions" ;; + hermes) CMD_FULL="${ISO_ENV_PREFIX}hermes" ;; + cline) CMD_FULL="cline -i${ISO_CMD_ARGS:+ $ISO_CMD_ARGS}" ;; +esac + spawn() { case "$AGENT" in claude) - if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then + # 격리 시 wrapper 경로는 env 주입을 운반하지 못하므로 인라인 spawn 강제 + if [ "$ISOLATE" != "1" ] && { { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; }; then nohup "$WRAPPER" >/dev/null 2>&1 & disown else - _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude --dangerously-skip-permissions" + _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" fi ;; - agy) - _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions" - ;; - hermes) - _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "hermes" - ;; - cline) - _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "cline -i" + agy|hermes|cline) + _tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" ;; *) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;; esac @@ -151,6 +185,12 @@ cleanup_tmux_on_error() { if [ $exit_code -ne 0 ]; then echo "⚠️ Error occurred during initialization. Rolling back and killing tmux session '$SESSION_NAME'..." >&2 _tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true + # T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm) + if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then + case "$ISOLATION_ROOT" in + */.mam/agent_homes/*) rm -rf "$ISOLATION_ROOT" ;; + esac + fi fi } trap cleanup_tmux_on_error EXIT @@ -165,15 +205,7 @@ PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/d TMUX_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') -# cmd_full 결정 -case "$AGENT" in - claude) CMD_FULL='claude --dangerously-skip-permissions' ;; - agy) CMD_FULL='agy --dangerously-skip-permissions' ;; - hermes) CMD_FULL='hermes' ;; - cline) CMD_FULL='cline -i' ;; -esac - -# 시작 명령 +# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4) local_tmux="tmux" if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then local_tmux="tmux -L $TMUX_SERVER_NAME" @@ -181,10 +213,10 @@ fi case "$AGENT" in claude) - if [ -x "$WRAPPER" ]; then + if [ "$ISOLATE" != "1" ] && [ -x "$WRAPPER" ]; then START_CMD="$WRAPPER # ~/.local/bin 의 래퍼" else - START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"claude --dangerously-skip-permissions\"" + START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" fi ;; agy|hermes|cline) @@ -238,7 +270,9 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \ CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \ TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \ - DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' + DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" \ + ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \ + ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] role = os.environ['ROLE'] @@ -277,6 +311,16 @@ entry = { 'kill_command': f'tmux {server_opt}kill-session -t {name}', } +# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용 +iso_uuid = os.environ.get('ISOLATION_UUID', '') +if iso_uuid: + entry['isolation'] = { + 'uuid': iso_uuid, + 'root': os.environ.get('ISOLATION_ROOT', ''), + 'lever': os.environ.get('ISOLATION_LEVER', ''), + 'seeded': [x for x in os.environ.get('ISOLATION_SEEDED', '').split(',') if x], + } + if agent == 'claude': entry['tui'] = { 'model': '(unknown — capture after first message)', diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index be92fb7..298c3aa 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -64,17 +64,18 @@ WORKSPACE=/path/to/project AGENT=claude # or agy or hermes SESSION_NAME=-creator- # same convention as multi-agent-mux-create -# 1. Resolve the session id +# Resolve the isolated tmux server name & load isolation utils +source .agents/skills/lib.sh + +# 1. Resolve the session id (T5: pass session name for target-row isolation check) UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \ - --workspace "$WORKSPACE" --agent "$AGENT") + --workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME") if [ -z "$UUID" ]; then echo "No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." exit 1 fi -# Resolve the isolated tmux server name -source .agents/skills/lib.sh export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")" # 2. If tmux is alive, attach. Done. @@ -83,11 +84,69 @@ if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then exec tmux attach -t "$SESSION_NAME" fi -# 3. Spawn new tmux session + run agent with the saved id +# 3. Resolve isolation settings for this session (T4/T5 re-apply) +# _get_session_isolation resolves isolation block for the session row +ISO_ROOT="" +ISO_ENV="" +ISO_ARGS="" +ISO_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF' +import os, json, yaml, sqlite3 +name = os.environ['SESSION_NAME'] +yaml_path = os.environ['YAML_PATH'] +db_path = os.path.splitext(yaml_path)[0] + '.db' +d = {} +try: + if os.path.exists(db_path): + conn = sqlite3.connect(db_path, timeout=60.0) + row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone() + if row: + s = json.loads(row[0]) + print(json.dumps(s.get('isolation') or {})) + raise SystemExit(0) + elif os.path.exists(yaml_path): + with open(yaml_path) as f: + d = yaml.safe_load(f) or {} +except Exception: + pass +for s in d.get('tmux_sessions', []): + if s.get('name') == name: + print(json.dumps(s.get('isolation') or {})) + raise SystemExit(0) +print("{}") +PYEOF +) +ISO_ROOT=$(printf '%s' "$ISO_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("root",""))') +if [ -n "$ISO_ROOT" ]; then + ISO_ENV="$(isolation_env_prefix "$AGENT" "$ISO_ROOT")" + ISO_ARGS="$(isolation_cmd_args "$AGENT" "$ISO_ROOT")" + echo "Re-applying isolation: root=$ISO_ROOT env=$ISO_ENV args=$ISO_ARGS" +fi + +# Determine CMD_FULL with isolation applied +case "$AGENT" in + claude) CMD_FULL="claude --dangerously-skip-permissions -r $UUID" ;; + agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;; + hermes) CMD_FULL="hermes --resume $UUID" ;; + cline) CMD_FULL="cline -i --id $UUID" ;; +esac + +# Prepend env prefix and append command args (T4) +if [ -n "$ISO_ENV" ]; then + CMD_FULL="$ISO_ENV $CMD_FULL" +fi +if [ -n "$ISO_ARGS" ]; then + CMD_FULL="$CMD_FULL $ISO_ARGS" +fi + +# 4. Spawn new tmux session + run agent with the saved id (and re-applied isolation) case "$AGENT" in claude) - tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \ - "claude --dangerously-skip-permissions -r $UUID" + if [ -z "$ISO_ROOT" ] && [ -x "$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude" ]; then + START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude\"" + else + START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" + fi + eval "$START_CMD" # auto-handle trust / bypass dialogs sleep 5 tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true @@ -96,17 +155,8 @@ case "$AGENT" in sleep 0.3 tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true ;; - agy) - tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \ - "agy --dangerously-skip-permissions --conversation $UUID" - ;; - hermes) - tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \ - "hermes --resume $UUID" - ;; - cline) - tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \ - "cline -i --id $UUID" + agy|hermes|cline) + eval "tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" ;; esac diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh index e719cdc..90d0c94 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh @@ -13,18 +13,22 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" usage() { cat < --agent +Usage: $0 --workspace --agent [--session ] Outputs the resolved UUID on stdout (empty if not found). +--session scopes resolution to that registry row — required for sessions +created with --isolate (their conversation lives only in the row's isolation root). EOF } WORKSPACE="" AGENT="" +SESSION_NAME="" while [ $# -gt 0 ]; do case "$1" in --workspace) WORKSPACE="$2"; shift 2 ;; --agent) AGENT="$2"; shift 2 ;; + --session) SESSION_NAME="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac @@ -37,4 +41,4 @@ case "$AGENT" in *) echo "ERROR: --agent must be claude or agy or hermes or cline" >&2; exit 2 ;; esac -find_workspace_uuid "$WORKSPACE" "$AGENT" +find_workspace_uuid "$WORKSPACE" "$AGENT" "$SESSION_NAME" diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 0789f5e..288e9fc 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -148,7 +148,7 @@ fi # purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지) PURGE_UUID="" if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then - PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" || true) + PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" "$SESSION_NAME" || true) fi NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') @@ -167,7 +167,7 @@ fi # 를 알아서 시도하므로 tmux 생사와 무관하게 동작. CAPTURED_UUID="" if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then - CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" || true) + CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true) if [ -n "$CAPTURED_UUID" ]; then echo "captured conversation id: $CAPTURED_UUID" else @@ -270,6 +270,23 @@ if captured and not purge: target['resumable'] = True # --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C) +# T6: stop-purge 시 격리 디렉터리 청소 및 경로 가드 +iso = target.get('isolation') +if purge and iso: + iso_root = iso.get('root') + iso_uuid = iso.get('uuid') + if iso_root and iso_uuid: + ws_abs = os.path.abspath(ws) if ws else "" + expected_homes_dir = os.path.join(ws_abs, '.mam', 'agent_homes') + expected_iso_root = os.path.join(expected_homes_dir, iso_uuid) + if (os.path.abspath(iso_root) == os.path.abspath(expected_iso_root) and + os.path.abspath(iso_root).startswith(os.path.abspath(expected_homes_dir) + os.sep)): + if os.path.isdir(iso_root): + shutil.rmtree(iso_root) + print(f"purged isolated home: {iso_root}", flush=True) + else: + print(f"WARN: isolated home path check failed: {iso_root}", flush=True) + if purge and purge_uuid: if agent == 'claude': key = ws.replace('/', '-').replace('_', '-') diff --git a/task.session_isolation.md b/task.session_isolation.md new file mode 100644 index 0000000..afc52cb --- /dev/null +++ b/task.session_isolation.md @@ -0,0 +1,66 @@ +# Task Checklist — 세션 ID 격리 (Rev.3) + +> 기준 문서: [implementation_plan.session_isolation.md](implementation_plan.session_isolation.md) (Rev.3) / [session_isolation_discussion.md](session_isolation_discussion.md) +> 담당: Developer Agent | 순서대로 수행하며 완료 시 `[x]` 갱신 +> **대전제: Phase 0 매트릭스 확정 전에는 Phase 2 이후 구현 착수 금지. Phase 1은 병렬 선행 가능.** + +--- + +## Phase 0 — 격리 레버 실측 (G2 프로브, 차단 게이트) + +> 모든 프로브는 스크래치 워크스페이스(예: `/tmp/mam-probe-*`)에서 수행. 실 워크스페이스/실 세션 오염 금지. + +- [x] **G2-C1. claude `CLAUDE_CONFIG_DIR` 쓰기 격리 실증** + - 격리 디렉터리 생성 → `.credentials.json`/`settings.json`/`plugins/` **심링크** 시딩 + - `CLAUDE_CONFIG_DIR=<격리경로> claude --dangerously-skip-permissions`로 기동, 1메시지 송신 + - 확인: (a) 대화 jsonl이 `<격리경로>/projects//`에 생성 (b) 로그인 유지 (c) `~/.claude/projects/`에는 미생성 +- [x] **G2-C2. claude 행동 드리프트 점검**: 심링크 시딩 상태에서 settings/plugins 정상 로드 확인, 추가 시딩 필요 파일 발견 시 목록에 추가 +- [x] **G2-L1. cline `--data-dir` 격리 실증** + - `cline -i --data-dir <격리경로> --config ~/.cline/data/settings` 기동, 1메시지 송신 + - 확인: (a) 세션이 `<격리경로>` 하위에 생성 (b) auth/providers 공유 config로 정상 동작 (c) `~/.cline/data/sessions/`에는 미생성 +- [x] **G2-A1. agy 격리 레버 실측**: `--new-project`/`--project` per-role 부여 시 대화 격리 여부, 데이터 경로 env 유무 → 부재 시 `HOME=<격리경로>`+시딩 유효성(시딩 목록 채록) +- [x] **G2-H1. hermes 격리 레버 실측**: home 이동 env/플래그 확인 → 부재 시 `HOME=<격리경로>`+시딩 유효성(시딩 목록 채록) +- [x] **G2-M. 매트릭스 확정**: `{agent × (레버, 시딩 목록, 대화 저장 실경로)}` 표를 plan §2.3에 기입 (Planner 갱신 요청) +- **DoD**: 4-agent 전부 "격리 디렉터리에서 대화 생성 + auth 유지" 실증 완료, 매트릭스 문서화 + +## Phase 1 — 공통 불변식 (R1/R2, Phase 0과 병렬 가능) + +- [x] **T1. claimed-set 필터** (`lib.sh:540-575`, `find_workspace_uuid`) + - Tier-2/Tier-3 후보 반환 전, 같은 workspace의 **다른 running row가 소유한 `*_own` 집합**을 제외 + - 4-agent(`claude_session_id_own`/`agy_…`/`hermes_…`/`cline_…`) 모두 적용 +- [x] **T2. 생성-시 유일성 assert** (`lib.sh` atomic_dump 뮤테이션 검증부, `:377` role-immutability 인접) + - 새 `*_own`이 기존 running row의 `*_own`과 충돌 시 `SystemExit`(비정상 종료 코드)로 거부 +- [x] **T-V1. 단위 재현**: 동일 ID 강제 주입으로 running row 2개 등록 시도 → 두 번째 거부 확인 +- **DoD**: 한 대화 ID가 두 role에 배정되는 경로가 구조적으로 차단됨을 재현 테스트로 증명 + +## Phase 2 — all-L2 격리 구현 (Phase 0 매트릭스 확정 후) + +- [x] **T3. 격리 프로비저닝** (`create_session.sh --isolate`) ✅ 2026-07-10 + - `uuidgen` → `/.mam/agent_homes//` 생성, `lib.sh::provision_isolation`이 agent별 심링크 시딩(매트릭스 기준) + seeded 목록 반환 + - dry-run 시 미생성 가드 / 실패 trap에서 격리 홈 롤백(경로 가드 후 rm) +- [x] **T4. spawn 주입 디스패치** (`lib.sh`: `isolation_lever`/`isolation_env_prefix`/`isolation_cmd_args`) ✅ + - claude·agy·hermes: env prefix(`CLAUDE_CONFIG_DIR`/`HOME`) / cline: `--data-dir` args — CMD_FULL/START_CMD/spawn 모두 단일 디스패치 경유 + - 격리 시 claude wrapper 경로 자동 비활성(env 운반 불가) / 격리 미사용 시 기존 명령과 byte-identical +- [x] **T5. `isolation` 스키마 영속화 + 재적용** ✅ + - row에 `isolation: {uuid, root, lever, seeded[]}` 기록(atomic_dump, DB+YAML 미러 확인) + `_validate` uuid/root 필수 검증 + - `find_workspace_uuid [session]` target-모드: 격리 row는 자기 격리 루트 안에서만 해석(전역 fallthrough 금지), cline `/sessions/` 경로 템플릿 분기 반영 + - `resolve_session_id.sh --session` / `stop_session.sh` capture·purge에 세션 컨텍스트 전달 / T1 claimed-set의 자기-ID 필터 버그 동시 수정 +- [ ] **T-V2. 격리 동작 검증(실 스폰)**: 같은 CLI 2개(다른 role) 동시 생성 → 대화 파일 물리 분리, `*_own` 상이, 교차 write 0 + - (스크래치 기능 검증은 완료: 동일 ws의 cline 2-row가 111_aaa/222_bbb로 상이 해석, claude iso jsonl 해석, 불량 isolation 거부, T2 중복 거부, 레거시 no-target 무회귀 — 실 TUI 동시 스폰만 잔여) +- **DoD**: create→resolve 경로 검증 완료 / resume 실측은 T-V2와 함께 + +## Phase 3 — stop 청소 계약 (T6) + +- [x] **T6. stop 퍼지** (`stop_session.sh`): row의 `isolation.root` `rm -rf` — 단, root가 `/.mam/agent_homes/` 하위임을 **경로 검증 후** 삭제(오삭제 방지 가드) ✅ 2026-07-10 +- [x] **T6-a. 심링크 원본 무손상 확인**: 퍼지 후 실HOME의 `.credentials.json`/settings/plugins 원본 잔존 검증 ✅ 2026-07-10 +- [ ] **T6-b. 고아 GC(선택)**: create 시 running row에 매핑되지 않는 `agent_homes/*` 정리 +- **DoD**: stop 후 격리 저장소 잔존 0 + 원본 무손상 + +## Phase 4 — 통합 및 회귀 검증 + +- [ ] **V1.** planner(claude)+reviewer-a(claude)+developer(cline)+reviewer-b(cline) 4개 동시 기동 → 4개 대화 ID 전부 유일 +- [ ] **V2.** 동시 write 락 충돌·컨텍스트 오염 재현 불가 (기존 재현 시나리오 역검증) +- [ ] **V3.** 각 role resume이 자기 대화만 복원 (isolation 재적용 경유) +- [ ] **V4.** 단일-에이전트 기존 워크플로우 회귀 0 (격리 미발동 경로 불변 — 발동 조건/플래그 확정 포함) +- [ ] **V5.** `bash -n` + `shellcheck` 신규 경고 0 (`lib.sh`, `create_session.sh`, `stop_session.sh`, resume 스크립트) +- **DoD**: V1~V5 전부 통과 → 리뷰어(Claude/Cline) 교차 리뷰 요청 → 전원 PASS 시 커밋