Migrate backend to herdr using a seamless translation shim wrapper

This commit is contained in:
2026-07-19 16:22:58 +09:00
parent 507ac1847b
commit 42b54d7643
7 changed files with 182 additions and 107 deletions
+152 -52
View File
@@ -44,75 +44,172 @@ _TMUX_SKILLS_BIN_PATTERN="${_TMUX_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}"
_resolve_real_tmux_path() {
if [ -z "${_REAL_TMUX_PATH:-}" ] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SHIM_DIR_PATTERN}"* ]] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
local dir save_ifs="$IFS"
_REAL_TMUX_PATH=""
IFS=:
for dir in $PATH; do
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]] && [ -x "$dir/tmux" ]; then
_REAL_TMUX_PATH="$dir/tmux"
break
fi
done
IFS="$save_ifs"
if [ -z "$_REAL_TMUX_PATH" ]; then
_REAL_TMUX_PATH="tmux"
fi
export _REAL_TMUX_PATH
fi
_REAL_TMUX_PATH="tmux"
export _REAL_TMUX_PATH
}
_init_tmux_isolation() {
_resolve_real_tmux_path
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local wrapper_dir="${TMPDIR:-/tmp}${_TMUX_SHIM_DIR_PATTERN}${TMUX_SERVER_NAME}"
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
mkdir -p "$wrapper_dir"
cat <<EOF > "$wrapper_dir/tmux"
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$wrapper_dir"
cat <<'EOF' > "$wrapper_dir/tmux"
#!/usr/bin/env bash
if [ -z "\${TMUX_SERVER_NAME:-}" ] || [ "\$TMUX_SERVER_NAME" = "default" ]; then
exec "$_REAL_TMUX_PATH" "\$@"
else
exec "$_REAL_TMUX_PATH" -L "\$TMUX_SERVER_NAME" "\$@"
# Herdr-to-Tmux translation shim wrapper
set -euo pipefail
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cmd="${1:-}"
if [ -z "$cmd" ]; then
echo "tmux shim: no command specified" >&2
exit 1
fi
EOF
chmod +x "$wrapper_dir/tmux"
export PATH="$wrapper_dir:$PATH"
fi
else
# 격리 비활성화 시 shim 자동 cleanup (PATH에서 제거)
local new_path="" dir save_ifs="$IFS"
IFS=:
for dir in $PATH; do
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
if [ -z "$new_path" ]; then
new_path="$dir"
else
new_path="$new_path:$dir"
fi
fi
shift
case "$cmd" in
has-session)
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
*) shift ;;
esac
done
IFS="$save_ifs"
export PATH="$new_path"
herdr agent get "$sess" >/dev/null 2>&1
;;
new-session)
name="" ws="" run_cmd=""
while [ $# -gt 0 ]; do
case "$1" in
-s) name="$2"; shift 2 ;;
-c) ws="$2"; shift 2 ;;
-d|-x|-y) shift ;;
*) run_cmd="$1"; shift ;;
esac
done
herdr workspace create --label "${TMUX_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true
# herdr agent start name workspace cwd argv
herdr agent start "$name" --workspace "${TMUX_SERVER_NAME:-default}" --cwd "${ws:-.}" -- $run_cmd
;;
kill-session)
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
*) shift ;;
esac
done
herdr session stop "$sess" >/dev/null 2>&1 || true
herdr session delete "$sess" >/dev/null 2>&1 || true
;;
list-panes)
sess="" format=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-F) format="$2"; shift 2 ;;
*) shift ;;
esac
done
info=$(herdr agent get "$sess" 2>/dev/null || true)
if [ -n "$info" ]; then
if [ "$format" = '#{pane_pid}|#{pane_current_path}|#{pane_current_command}' ]; then
python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
p = d.get('pane', d)
print(f\"{p.get('pid', '')}|{p.get('cwd', '')}|{p.get('command', p.get('argv', ''))}\")
except Exception:
pass
" <<< "$info"
elif [ "$format" = '#{pane_pid}' ]; then
python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('pid', ''))" <<< "$info"
elif [ "$format" = '#{pane_current_path}' ]; then
python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('cwd', ''))" <<< "$info"
elif [ "$format" = '#{pane_current_command}' ]; then
python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('command', d.get('pane', d).get('argv', '')))" <<< "$info"
fi
fi
;;
capture-pane)
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
*) shift ;;
esac
done
herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
;;
send-keys)
sess="" key=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
*) key="$1"; shift ;;
esac
done
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
key="Enter"
fi
herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
;;
set-buffer)
text=""
while [ $# -gt 0 ]; do
case "$1" in
-b) buf="$2"; shift 2 ;;
*) text="$1"; shift ;;
esac
done
echo -n "$text" > "$wrapper_dir/tmp_buffer"
;;
paste-buffer)
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -f "$wrapper_dir/tmp_buffer" ]; then
herdr agent send "$sess" "$(cat "$wrapper_dir/tmp_buffer")" >/dev/null 2>&1 || true
fi
;;
delete-buffer)
rm -f "$wrapper_dir/tmp_buffer"
;;
ls)
herdr session list --json 2>/dev/null | python3 -c "
import sys, json
try:
for s in json.load(sys.stdin):
print(f\"{s['name']}|{s.get('created_at_epoch', 0)}\")
except Exception:
pass
" || true
;;
*)
true
;;
esac
EOF
chmod +x "$wrapper_dir/tmux"
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
}
mam_tmux() {
_resolve_real_tmux_path
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
"$_REAL_TMUX_PATH" -L "$TMUX_SERVER_NAME" "$@"
else
"$_REAL_TMUX_PATH" "$@"
fi
_init_tmux_isolation
"$WORKSPACE_ROOT/.mam/shim/tmux" "$@"
}
_tmux() {
_init_tmux_isolation
mam_tmux "$@"
}
tmux() {
_tmux "$@"
mam_tmux "$@"
}
# ---------------------------------------------------------------------------
@@ -1239,3 +1336,6 @@ handle_startup_dialogs() {
done
return 0
}
# Auto-initialize the herdr translation shim wrapper on library source
_init_tmux_isolation
@@ -79,7 +79,7 @@ fi
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
command -v tmux >/dev/null || { echo "ERROR: tmux not installed" >&2; exit 1; }
command -v herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; }
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
# Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes)
@@ -231,23 +231,7 @@ TMUX_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 시작 명령 (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"
fi
case "$AGENT" in
claude)
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\" \"$CMD_FULL\""
fi
;;
agy|hermes|cline)
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
;;
esac
START_CMD="herdr agent start \"$SESSION_NAME\" --workspace \"${TMUX_SERVER_NAME:-default}\" --cwd \"$WORKSPACE\" -- $CMD_FULL"
# If --onboard is specified, automatically build the onboarding prompt
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
@@ -332,8 +316,8 @@ entry = {
'cwd': os.environ['PANE_CWD'],
},
'start_command': os.environ['START_CMD'],
'attach_command': f'tmux {server_opt}attach -t {name}',
'kill_command': f'tmux {server_opt}kill-session -t {name}',
'attach_command': f'herdr session attach {name}',
'kill_command': f'herdr session stop {name} && herdr session delete {name}',
}
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
@@ -421,10 +405,6 @@ fi
trap - EXIT
echo "agent-sessions.yaml updated"
echo
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
echo "Attach: tmux -L $TMUX_SERVER_NAME attach -t $SESSION_NAME"
else
echo "Attach: tmux attach -t $SESSION_NAME"
fi
echo "Attach: herdr session attach $SESSION_NAME"
echo "Delete: use multi-agent-mux-stop skill"
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
@@ -120,22 +120,13 @@ if [ -n "$ISO_ARGS" ]; then
CMD_FULL="$CMD_FULL $ISO_ARGS"
fi
# 4. Spawn new tmux session + run agent with the saved id
case "$AGENT" in
claude)
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
handle_startup_dialogs "$SESSION_NAME" 20
;;
agy|hermes|cline)
eval "tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
;;
esac
# 4. Spawn new agent session via herdr
herdr workspace create --label "${TMUX_SERVER_NAME:-default}" --cwd "$WORKSPACE" >/dev/null 2>&1 || true
herdr agent start "$SESSION_NAME" --workspace "${TMUX_SERVER_NAME:-default}" --cwd "$WORKSPACE" -- $CMD_FULL
if [ "$AGENT" = "claude" ]; then
# auto-handle trust / bypass dialogs
handle_startup_dialogs "$SESSION_NAME" 20
fi
# Wait for TUI readiness or let it settle
sleep 2
@@ -189,9 +189,9 @@ def get_job_status(s):
sessions = d.get('tmux_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (tmux_confirmed={drift['tmux_confirmed']})")
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['tmux_confirmed']})")
print("=" * 136)
print(f"{'NAME':<44} {'SERVER':<12} {'YAML':<10} {'TMUX':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print("-" * 136)
if not sessions:
print("(no sessions registered)")
@@ -199,12 +199,12 @@ for s in sessions:
name = s.get('name', '?')
server = s.get('tmux_server') or 'default'
status = s.get('status', '?')
tmux = 'alive' if f"{name}|{server}" in alive else 'dead'
herdr = 'alive' if f"{name}|{server}" in alive 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} {status:<10} {tmux:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
print(f"{name:<44} {server:<12} {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]
@@ -213,5 +213,5 @@ if extra:
for dr in extra:
print(f" [{dr['class']}] {dr['msg']}")
print("=" * 136)
print(f"alive tmux: {sorted(alive)}")
print(f"alive herdr: {sorted(alive)}")
PYEOF
@@ -201,9 +201,9 @@ if [ "$PURGE" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
echo " Refusing registry removal — records preserved (no state was modified)." >&2
echo " Diagnose the stuck TUI (tmux attach -t '$SESSION_NAME'), then re-run" >&2
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
delegate_publish_event "$DELEGATE_JOB_ID" error "purge aborted: tmux session still alive"
delegate_publish_event "$DELEGATE_JOB_ID" error "purge aborted: herdr session still alive"
exit 4
fi
fi
+6 -6
View File
@@ -8,7 +8,7 @@ MAM은 단일 워크스페이스 상에서 복수의 에이전트(Claude, Cline,
## 1. ⚙️ 사전 요구사항
MAM 스킬 및 스크립트들은 호스트 시스템의 다음 도구들에 의존합니다. 설치 전에 확인해 주세요.
* **tmux**: 에이전트를 백그라운드 격리 Pane에서 구동하기 위한 프로세스 컨테이너
* **herdr**: 에이전트를 백그라운드 격리 Pane/Workspace에서 구동 및 관제하기 위한 프로세스 컨테이너
* **python3**: 세션 레지스트리(YAML/SQLite DB) 파싱 및 유효성 검사 (내장 `sqlite3` 모듈 필수)
* **uuidgen**: 격리 세션 생성 시 고유의 UUID 할당
* **rsync**: 인스톨러(`deploy/install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
@@ -54,14 +54,14 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
--role developer \
--session my-project-dev-claude \
--isolate \
--tmux-server multi-agent-mux
--herdr-workspace multi-agent-mux
```
* `--isolate` 옵션을 주면 `.mam/agent_homes/<uuid>/` 하위에 로그인 및 설정은 유지하되 대화 내역은 격리되는 홈이 형성됩니다.
### 2) 세션 접속 (Attach)
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다. (세션 생성 시 지정한 독립 격리 tmux 서버 소켓 `-L multi-agent-mux` 를 경유해 접속합니다.)
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
```bash
$ tmux -L multi-agent-mux attach -t my-project-dev-claude
$ herdr session attach my-project-dev-claude
```
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
@@ -81,8 +81,8 @@ $ [ -n "$UUID" ] || { echo "[ERROR] 매칭되는 활성 세션 이력이 없습
# 2단계: 세션 재기동 (이전 대화 컨텍스트 복원 기동)
# (주의: 만약 create 시 격리(--isolate) 세션으로 생성했다면, CLAUDE_CONFIG_DIR 환경변수를 YAML에 기록된 isolation.root 경로로 지정하여 띄워야 합니다. 상세 격리 복원 커맨드는 .agents/skills/multi-agent-mux-resume/SKILL.md 문서를 필독해 주세요.)
$ tmux -L multi-agent-mux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
"claude --dangerously-skip-permissions -r $UUID; echo 'CLAUDE EXIT'; bash"
$ herdr run -d --name "$SESSION_NAME" --workspace "$WORKSPACE" -- \
"claude --dangerously-skip-permissions -r $UUID"
# 3단계: 레지스트리 세션 상태를 running 으로 동기화 갱신
$ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
+5 -1
View File
@@ -83,7 +83,7 @@ fi
# 1. Dependency Checks
log_info "Verifying host dependencies..."
DEPS=(tmux python3 rsync uuidgen)
DEPS=(herdr python3 rsync uuidgen)
MISSING_DEPS=()
for dep in "${DEPS[@]}"; do
if ! command -v "$dep" &>/dev/null; then
@@ -93,6 +93,10 @@ done
if [ ${#MISSING_DEPS[@]} -ne 0 ]; then
log_error "Missing required dependencies: ${MISSING_DEPS[*]}"
if [[ " ${MISSING_DEPS[*]} " == *" herdr "* ]]; then
log_error "To install herdr, please refer to the official installation guide:"
log_error " curl -fsSL https://herdr.dev/install.sh | sh"
fi
log_error "Please install them before using MAM."
exit 1
fi