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