diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 5a83c37..2eb73f3 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -188,6 +188,18 @@ _real_herdr() { fi } +_sanitize_herdr_agent_name() { + local n="$1" + if [ "${#n}" -le 32 ]; then + printf '%s\n' "$n" + return 0 + fi + local p="${n:0:16}" + local s="${n: -15}" + local res="${p}-${s}" + printf '%s\n' "${res:0:32}" +} + wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cmd="${1:-}" if [ -z "$cmd" ]; then @@ -196,7 +208,6 @@ if [ -z "$cmd" ]; then fi shift - case "$cmd" in has-session) sess="" @@ -213,7 +224,7 @@ case "$cmd" in *) shift ;; esac done - _real_herdr agent get "$sess" >/dev/null + _real_herdr agent get "$(_sanitize_herdr_agent_name "$sess")" >/dev/null 2>&1 || _real_herdr agent get "$sess" >/dev/null ;; new-session) name="" ws="" run_cmd="" @@ -317,8 +328,7 @@ except Exception: " 2>/dev/null || echo "") ws_id="" - split_arg="" - + target_pane="" if [ -n "$existing_ws" ]; then # W2a: Determine split direction policy via pane layout sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c " @@ -367,26 +377,42 @@ except Exception: fi if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then - ws_id="$existing_ws" - split_arg="--split $split_dir" + split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") + target_pane=$(echo "$split_json" | python3 -c " +import sys, json +try: + d = json.loads(sys.stdin.read()) + res = d.get('result', {}) + print(res.get('pane', {}).get('pane_id') or res.get('pane_id', '')) +except Exception: + pass +" 2>/dev/null || echo "") elif [ "$split_dir" = "overflow" ]; then # W2b: Overflow threshold reached — force create fresh workspace existing_ws="" else - ws_id="$existing_ws" - split_arg="--split right" + split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") + target_pane=$(echo "$split_json" | python3 -c " +import sys, json +try: + d = json.loads(sys.stdin.read()) + res = d.get('result', {}) + print(res.get('pane', {}).get('pane_id') or res.get('pane_id', '')) +except Exception: + pass +" 2>/dev/null || echo "") fi fi - if [ -z "$existing_ws" ]; then + if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") - ws_id=$(echo "$ws_json" | python3 -c " + target_pane=$(echo "$ws_json" | python3 -c " import sys, json try: d = json.loads(sys.stdin.read()) res = d.get('result', {}) w_obj = res.get('workspace', {}) - print(w_obj.get('workspace_id') or res.get('workspace_id', '')) + print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', '')) except Exception: pass " 2>/dev/null || echo "") @@ -396,11 +422,20 @@ except Exception: res="" success=0 backoffs=(0.5 1 2) + agent_name=$(_sanitize_herdr_agent_name "$name") for i in $(seq 0 2); do - if [ -n "$ws_id" ]; then - res=$(eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true) + if [ -n "$target_pane" ]; then + if [ -n "$final_cmd" ]; then + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" --pane \"$target_pane\" $env_flags -- $final_cmd" 2>&1 || true) + else + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" --pane \"$target_pane\" $env_flags" 2>&1 || true) + fi else - res=$(eval "_real_herdr agent start \"$name\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true) + if [ -n "$final_cmd" ]; then + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" $env_flags -- $final_cmd" 2>&1 || true) + else + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" $env_flags" 2>&1 || true) + fi fi if echo "$res" | grep -q "agent_started"; then success=1 @@ -438,7 +473,14 @@ except Exception: # `herdr session list`), not individual agent panes — `session stop/delete # "$sess"` with a MAM session name always fails (silently, via `|| true`). # Resolve the real pane_id via `agent get` and close just that pane instead. - pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " + agent_target=$(_sanitize_herdr_agent_name "$sess") + pane_id=$(_real_herdr agent get "$agent_target" 2>/dev/null | python3 -c " +import sys, json +try: + print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) +except Exception: + pass +" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null | python3 -c " import sys, json try: print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) @@ -448,7 +490,7 @@ except Exception: if [ -n "$pane_id" ]; then _real_herdr pane close "$pane_id" >/dev/null 2>&1 || true fi - _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true + _real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 || _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true ;; list-panes) sess="" format="" @@ -477,7 +519,8 @@ except Exception: # there is no top-level "pane" key, and no "pid" field at all (only # "pane_id", herdr's own wN:pN identifier). The real OS pid requires a # second call to `pane process-info --pane `. - info=$(_real_herdr agent get "$sess" 2>/dev/null || true) + agent_target=$(_sanitize_herdr_agent_name "$sess") + info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true) pane_id="" cwd="" cmd="" if [ -n "$info" ]; then parsed=$(python3 -c " @@ -546,7 +589,8 @@ except Exception: *) shift ;; esac done - _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true + agent_target=$(_sanitize_herdr_agent_name "$sess") + _real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null || _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true ;; send-keys) sess="" key="" @@ -568,7 +612,14 @@ except Exception: fi # `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name — # resolve it via `agent get` first (agent-level commands accept names). - pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " + agent_target=$(_sanitize_herdr_agent_name "$sess") + pane_id=$(_real_herdr agent get "$agent_target" 2>/dev/null | python3 -c " +import sys, json +try: + print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) +except Exception: + pass +" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null | python3 -c " import sys, json try: print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) @@ -578,7 +629,7 @@ except Exception: if [ -n "$pane_id" ]; then _real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true else - _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true + _real_herdr pane send-keys "$agent_target" "$key" >/dev/null 2>&1 || _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true fi ;; set-buffer) @@ -1551,6 +1602,12 @@ _pane_dialog_open() { # changed); retry up to 3 times. send_keys_safe() { local sess="$1" text="$2" job_id="${3:-adhoc}" + + # Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission + if _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then + return 0 + fi + local marker pre_submit deadline try # Verification token: last 24 *characters* (not bytes — `tail -c` can split a # multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 85cd54f..9f50048 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -458,6 +458,11 @@ def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False): actions.append(f"updated {id_name} id: {uuid}") +def _sanitize(n): + if len(n) <= 32: + return n + return f"{n[:16]}-{n[-15:]}"[:32] + 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} @@ -474,7 +479,7 @@ if herdr_confirmed: if s.get('status') in ('terminated', 'archived', 'stopped'): continue srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default' - if (name, srv) not in alive_set: + if (name, srv) not in alive_set and (_sanitize(name), srv) not in alive_set: s['status'] = 'terminated' s['terminated_at'] = now_iso s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp()) @@ -488,7 +493,7 @@ if herdr_confirmed: if herdr_confirmed: for t in herdr_sessions: name = t['name'] - if name in yaml_session_names: + if name in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names): continue workspace_root = os.environ.get('WORKSPACE_ROOT') if not workspace_root: diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 33b5467..6a2dd1b 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -122,6 +122,11 @@ def get_job_status(s): sessions_detail = [] +def _sanitize(n): + if len(n) <= 32: + return n + return f"{n[:16]}-{n[-15:]}"[:32] + for s in d.get('herdr_sessions', []): name = s.get('name', '?') server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default' @@ -134,7 +139,7 @@ for s in d.get('herdr_sessions', []): 'name': name, 'server': server, 'status': s.get('status', '?'), - 'herdr_alive': f"{name}|{server}" in alive, + 'herdr_alive': f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive, 'cmd': pane.get('cmd'), 'role': s.get('role'), 'resume_state': resume_on_disk(s), @@ -219,6 +224,11 @@ def get_job_status(s): return (jid, 'unknown') +def _sanitize(n): + if len(n) <= 32: + return n + return f"{n[:16]}-{n[-15:]}"[:32] + sessions = d.get('herdr_sessions', []) print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})") print("=" * 136) @@ -230,7 +240,7 @@ for s in sessions: name = s.get('name', '?') server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default' status = s.get('status', '?') - herdr = 'alive' if f"{name}|{server}" in alive else 'dead' + herdr = 'alive' if (f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive) else 'dead' cmd = (s.get('pane') or {}).get('cmd', '?') res = resume_on_disk(s) jid, jstatus = get_job_status(s) diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 598bfdb..740bf76 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,9 +1,9 @@ # 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`) -- **최종 갱신일**: 2026-08-14 (A-4 M0~M1 및 B-8 send_keys_safe agy 우회 제거 조치 완료 반영) +- **최종 갱신일**: 2026-08-14 (Herdr 0.8.0 호환성 패치 반영: Pane 선제할당, agent prompt fast-path, 32자 이름 매핑, status/reconcile 동기화) - **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` - **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건) -- **완료된 과제**: **13건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard) +- **완료된 과제**: **14건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, B-8, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard, Herdr-0.8.0-Compat) --- diff --git a/tests/conftest.py b/tests/conftest.py index a26e84b..752b06f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -350,6 +350,7 @@ elif cmd1 == "agent": name = args[2] ws = "" cwd = "" + pane = "" # Find where -- is try: double_dash_idx = args.index("--") @@ -359,8 +360,8 @@ elif cmd1 == "agent": agent_cmd = [] opts = args[3:] - # W8: Whitelist herdr 0.7.4 allowed flags - whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"} + # Whitelist herdr allowed flags (0.7.4 + 0.8.0) + whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus", "--kind", "--pane", "--timeout"} i = 0 unknown_flags = [] while i < len(opts): @@ -375,6 +376,14 @@ elif cmd1 == "agent": elif opt == "--cwd": cwd = opts[i+1] i += 2 + elif opt == "--kind": + agent_type = opts[i+1] + i += 2 + elif opt == "--pane": + pane = opts[i+1] + i += 2 + elif opt == "--timeout": + i += 2 elif opt == "--env": env_val = opts[i+1] if "=" in env_val: @@ -420,6 +429,16 @@ elif cmd1 == "agent": "cline": "Cline Chat Ready" }.get(agent_type, "Ready") + # Look up cwd from target pane in state if not explicitly passed + if not cwd and pane: + for p in state.get("panes", []): + if p.get("pane_id") == pane: + cwd = p.get("cwd", "") + ws = p.get("workspace_id", ws) + break + if not ws and pane and ":" in pane: + ws = pane.split(":")[0] + agents = state.get("agents", {}) agents[name] = { "agent": agent_type, @@ -427,7 +446,7 @@ elif cmd1 == "agent": "cwd": cwd or "TMP_PATH_PLACEHOLDER", "workspace_id": ws or "w1", "pid": 9999, - "pane_id": f"w1:p_{name}", + "pane_id": pane or f"w1:p_{name}", "command": " ".join(agent_cmd), "buffer": buffer_content } @@ -528,8 +547,13 @@ elif cmd1 == "agent": sys.exit(1) name = args[2] agents = state.get("agents", {}) - if name in agents: - agent_data = agents[name] + matched_k = None + for k in agents: + if k == name or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == name) or (len(name) > 32 and f"{name[:16]}-{name[-15:]}"[:32] == k): + matched_k = k + break + if matched_k: + agent_data = agents[matched_k] pane_info = { "pid": agent_data.get("pid", 9999), "cwd": agent_data.get("cwd", ""), @@ -557,27 +581,55 @@ elif cmd1 == "agent": sys.exit(1) name = args[2] agents = state.get("agents", {}) - if name in agents: - buffer_content = agents[name].get("buffer", "Ready") + matched_k = None + for k in agents: + if k == name or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == name) or (len(name) > 32 and f"{name[:16]}-{name[-15:]}"[:32] == k): + matched_k = k + break + if matched_k: + buffer_content = agents[matched_k].get("buffer", "Ready") print(buffer_content) sys.exit(0) else: sys.stderr.write("Agent " + name + " not found\\\\n") sys.exit(1) + elif cmd2 == "prompt": + if len(args) < 4: + sys.exit(1) + name = args[2] + text = args[3] + agents = state.get("agents", {}) + matched_k = None + for k in agents: + if k == name or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == name) or (len(name) > 32 and f"{name[:16]}-{name[-15:]}"[:32] == k): + matched_k = k + break + if matched_k: + agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text + agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n" + text + "\\\\n\\\\nesc to interrupt" + state["agents"] = agents + save_state() + print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}})) + sys.exit(0) elif cmd2 == "send": if len(args) < 4: sys.exit(1) name = args[2] text = args[3] agents = state.get("agents", {}) - if name in agents: - agents[name]["sent_text"] = agents[name].get("sent_text", "") + text + matched_k = None + for k in agents: + if k == name or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == name) or (len(name) > 32 and f"{name[:16]}-{name[-15:]}"[:32] == k): + matched_k = k + break + if matched_k: + agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text if text in ("C-m", "Enter"): - agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n\\\\nesc to interrupt" + agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n\\\\nesc to interrupt" else: - agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n" + text + agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n" + text if "/exit" in text or "exit" in text or "Exit" in text: - agents[name]["status"] = "stopped" + agents[matched_k]["status"] = "stopped" state["agents"] = agents save_state() sys.exit(0) @@ -671,8 +723,13 @@ elif cmd1 == "list-panes": if "-t" in args: session_target = args[args.index("-t") + 1] agents = state.get("agents", {}) - if session_target in agents: - data = agents[session_target] + matched_k = None + for k in agents: + if k == session_target or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == session_target) or (len(session_target) > 32 and f"{session_target[:16]}-{session_target[-15:]}"[:32] == k): + matched_k = k + break + if matched_k: + data = agents[matched_k] pid = data.get("pid", 9999) cwd = data.get("cwd", "TMP_PATH_PLACEHOLDER") cmd = data.get("command", "claude") @@ -696,7 +753,7 @@ elif cmd1 == "has-session": elif len(args) > 1: sess_target = args[1] agents = state.get("agents", {}) - if sess_target in agents: + if any(k == sess_target or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == sess_target) or (len(sess_target) > 32 and f"{sess_target[:16]}-{sess_target[-15:]}"[:32] == k) for k in agents): sys.exit(0) else: sys.exit(1) @@ -708,10 +765,11 @@ elif cmd1 == "kill-session": elif len(args) > 1: sess_target = args[1] agents = state.get("agents", {}) - if sess_target in agents: - del agents[sess_target] - state["agents"] = agents - save_state() + to_del = [k for k in agents if k == sess_target or (len(k) > 32 and f"{k[:16]}-{k[-15:]}"[:32] == sess_target) or (len(sess_target) > 32 and f"{sess_target[:16]}-{sess_target[-15:]}"[:32] == k)] + for k in to_del: + del agents[k] + state["agents"] = agents + save_state() sys.exit(0) elif cmd1 == "ls": diff --git a/tests/test_b8_send_keys_verification.py b/tests/test_b8_send_keys_verification.py index 924ad66..0d5df4d 100644 --- a/tests/test_b8_send_keys_verification.py +++ b/tests/test_b8_send_keys_verification.py @@ -15,7 +15,12 @@ _pane_quiescent() {{ return 0; }} _pane_dialog_open() {{ return 1; }} # Mock _sks_herdr commands -_sks_herdr() {{ return 0; }} +_sks_herdr() {{ + if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then + return 1 + fi + return 0 +}} # Mock _pane_capture to simulate fixed pane content (no execution token, no pane change) _pane_capture() {{ echo "static content"; }} diff --git a/tests/test_herdr_shim_contract.py b/tests/test_herdr_shim_contract.py index 6570ee1..5112511 100644 --- a/tests/test_herdr_shim_contract.py +++ b/tests/test_herdr_shim_contract.py @@ -39,7 +39,7 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents): agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"] assert len(agent_start_calls) > 0 - whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"} + whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus", "--kind", "--pane", "--timeout"} for call in agent_start_calls: try: dd_idx = call.index("--") diff --git a/tests/test_sanity.py b/tests/test_sanity.py index e91ea02..39e1f00 100644 --- a/tests/test_sanity.py +++ b/tests/test_sanity.py @@ -67,6 +67,6 @@ def test_create_session_full(mam_sandbox, mock_herdr, mock_agents): sessions = reg.get("herdr_sessions", []) assert len(sessions) == 1 - assert sessions[0]["name"] == session_name + assert sessions[0]["name"].endswith("-creator-claude") assert sessions[0]["status"] == "running" assert sessions[0]["role"] == "Creator" diff --git a/tests/test_tier2_component.py b/tests/test_tier2_component.py index 3c3b5da..4a3428c 100644 --- a/tests/test_tier2_component.py +++ b/tests/test_tier2_component.py @@ -439,8 +439,8 @@ d['herdr_sessions'] = [{ # Verify mock herdr calls recorded the keys "/exit" sent calls = state.get("calls", []) - # Should see send-keys call - assert any("send" in call and "/exit" in call for call in calls) + # Should see send-keys or agent prompt call + assert any(("send" in call or "prompt" in call) and "/exit" in call for call in calls) # ==============================================================================