Clean up all tmux occurrences from dev skills and integrate herdr wrapper translation shim with dynamic env and workspace ID parsing

This commit is contained in:
2026-07-19 16:57:00 +09:00
parent 42b54d7643
commit 30e606b0fa
20 changed files with 480 additions and 396 deletions
+176 -91
View File
@@ -3,7 +3,7 @@
#
# Single source of truth for the four things that were inconsistently
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
# - derive_session_name : the tmux session slug (P0-A)
# - derive_session_name : the herdr session slug (P0-A)
# - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B)
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
@@ -25,6 +25,13 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SKILL_DIR/../.." && pwd)"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
export PATH="$dir:$PATH"
fi
done
# Central TUI dialog and readiness validation tokens (OP-6)
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
@@ -35,31 +42,49 @@ CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
# ---------------------------------------------------------------------------
# Tmux Server Isolation support
# Herdr Server Isolation support
# ---------------------------------------------------------------------------
# Paths to exclude when resolving the real tmux binary (shim/wrapper dirs).
_TMUX_SHIM_DIR_PATTERN="${_TMUX_SHIM_DIR_PATTERN:-/multi-agent-tmux-shim/}"
_TMUX_SKILLS_BIN_PATTERN="${_TMUX_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
# Paths to exclude when resolving the real herdr binary (shim/wrapper dirs).
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
_HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}"
HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-default}"
_resolve_real_tmux_path() {
_REAL_TMUX_PATH="tmux"
export _REAL_TMUX_PATH
_resolve_real_herdr_path() {
_REAL_HERDR_PATH="herdr"
export _REAL_HERDR_PATH
}
_init_tmux_isolation() {
_init_herdr_isolation() {
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$wrapper_dir"
cat <<'EOF' > "$wrapper_dir/tmux"
cat <<'EOF' > "$wrapper_dir/herdr"
#!/usr/bin/env bash
# Herdr-to-Tmux translation shim wrapper
# Herdr-to-Herdr translation shim wrapper
set -euo pipefail
# Dynamic real herdr path resolution to prevent infinite recursion
_resolve_real_herdr() {
local dir save_ifs="$IFS" real_path=""
IFS=:
for dir in $PATH; do
if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then
real_path="$dir/herdr"
break
fi
done
IFS="$save_ifs"
if [ -z "$real_path" ]; then
real_path="herdr"
fi
echo "$real_path"
}
REAL_HERDR=$(_resolve_real_herdr)
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cmd="${1:-}"
if [ -z "$cmd" ]; then
echo "tmux shim: no command specified" >&2
echo "herdr shim: no command specified" >&2
exit 1
fi
shift
@@ -73,7 +98,7 @@ case "$cmd" in
*) shift ;;
esac
done
herdr agent get "$sess" >/dev/null 2>&1
"$REAL_HERDR" agent get "$sess" >/dev/null 2>&1
;;
new-session)
name="" ws="" run_cmd=""
@@ -85,9 +110,48 @@ case "$cmd" in
*) 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
"$REAL_HERDR" workspace create --label "${HERDR_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true
parsed=$(python3 -c "
import sys, shlex
cmd = sys.argv[1]
tokens = shlex.split(cmd)
env_flags = []
binary_tokens = []
for tok in tokens:
if '=' in tok and not binary_tokens:
env_flags.append('--env ' + tok)
else:
binary_tokens.append(shlex.quote(tok))
print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
" "$run_cmd" 2>/dev/null || echo "")
env_flags=""
final_cmd="$run_cmd"
if [ -n "$parsed" ]; then
env_flags=$(echo "$parsed" | head -n 1 | tr '\t' ' ')
final_cmd=$(echo "$parsed" | tail -n +2)
fi
ws_label="${HERDR_SERVER_NAME:-default}"
ws_id="w1"
if [ "$ws_label" != "default" ]; then
ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | python3 -c "
import sys, json
try:
data = json.loads(sys.stdin.read())
res = data.get('result', data)
for ws in res.get('workspaces', []):
if ws.get('label') == '$ws_label' or ws.get('workspace_id') == '$ws_label':
print(ws.get('workspace_id'))
sys.exit(0)
except Exception:
pass
")
ws_id="${ws_id:-w1}"
fi
eval "\"$REAL_HERDR\" agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $env_flags -- $final_cmd"
;;
kill-session)
sess=""
@@ -97,8 +161,8 @@ case "$cmd" in
*) shift ;;
esac
done
herdr session stop "$sess" >/dev/null 2>&1 || true
herdr session delete "$sess" >/dev/null 2>&1 || true
"$REAL_HERDR" session stop "$sess" >/dev/null 2>&1 || true
"$REAL_HERDR" session delete "$sess" >/dev/null 2>&1 || true
;;
list-panes)
sess="" format=""
@@ -109,7 +173,7 @@ case "$cmd" in
*) shift ;;
esac
done
info=$(herdr agent get "$sess" 2>/dev/null || true)
info=$("$REAL_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 "
@@ -138,7 +202,7 @@ except Exception:
*) shift ;;
esac
done
herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
"$REAL_HERDR" agent read "$sess" --source visible --lines 100 2>/dev/null || true
;;
send-keys)
sess="" key=""
@@ -151,7 +215,7 @@ except Exception:
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
key="Enter"
fi
herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
"$REAL_HERDR" pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
;;
set-buffer)
text=""
@@ -172,51 +236,53 @@ except Exception:
esac
done
if [ -f "$wrapper_dir/tmp_buffer" ]; then
herdr agent send "$sess" "$(cat "$wrapper_dir/tmp_buffer")" >/dev/null 2>&1 || true
"$REAL_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 "
"$REAL_HERDR" agent list 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)}\")
data = json.load(sys.stdin)
res = data.get('result', data)
for a in res.get('agents', []):
print(f\"{a['name']}|0\")
except Exception:
pass
" || true
;;
*)
true
"$REAL_HERDR" "$cmd" "$@"
;;
esac
EOF
chmod +x "$wrapper_dir/tmux"
chmod +x "$wrapper_dir/herdr"
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
}
mam_tmux() {
_init_tmux_isolation
"$WORKSPACE_ROOT/.mam/shim/tmux" "$@"
mam_herdr() {
_init_herdr_isolation
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
}
_tmux() {
mam_tmux "$@"
_herdr() {
mam_herdr "$@"
}
tmux() {
mam_tmux "$@"
herdr() {
mam_herdr "$@"
}
# ---------------------------------------------------------------------------
# resolve_tmux_server <session_name>
# resolve_herdr_server <session_name>
#
# Query agent-sessions.yaml to find the tmux_server associated with a session.
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
# Query agent-sessions.yaml to find the herdr_server associated with a session.
# Fallback to HERDR_SERVER_NAME or 'default' if not registered or field is missing.
# Prints the resolved server name on stdout.
# ---------------------------------------------------------------------------
load_state_json() {
@@ -236,7 +302,7 @@ try:
cursor = conn.execute('SELECT data FROM sessions')
for r in cursor.fetchall():
db_sessions.append(json.loads(r[0]))
d['tmux_sessions'] = db_sessions
d['herdr_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
conn.close()
@@ -259,24 +325,43 @@ print(json.dumps(d, ensure_ascii=False))
PYEOF
}
resolve_tmux_server() {
resolve_herdr_workspace() {
local session_name="$1"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
local raw_label
raw_label=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('tmux_server', 'default'))
print(s.get('herdr_workspace', 'default'))
sys.exit(0)
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
"
print(os.environ.get('HERDR_SERVER_NAME', 'default'))
")
if [ "$raw_label" = "default" ]; then
echo "w1"
else
local ws_id
ws_id=$(herdr workspace list 2>/dev/null | python3 -c "
import sys, json
try:
data = json.loads(sys.stdin.read())
res = data.get('result', data)
for ws in res.get('workspaces', []):
if ws.get('label') == '$raw_label' or ws.get('workspace_id') == '$raw_label':
print(ws.get('workspace_id'))
sys.exit(0)
except Exception:
pass
")
echo "${ws_id:-w1}"
fi
}
# ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent>
#
# THE single source of truth for the tmux session name. Rule:
# THE single source of truth for the herdr session name. Rule:
# slug = the two trailing path components of the absolute workspace,
# '_' -> '-', lowercased, joined with '-'
# name = "<slug>-creator-<agent>"
@@ -417,33 +502,33 @@ db_path = os.path.splitext(yaml_path)[0] + '.db'
def _validate(d):
if not isinstance(d, dict):
raise SystemExit("VALIDATE: top-level is not a mapping")
sessions = d.get('tmux_sessions', [])
sessions = d.get('herdr_sessions', [])
if not isinstance(sessions, list):
raise SystemExit("VALIDATE: tmux_sessions is not a list")
raise SystemExit("VALIDATE: herdr_sessions is not a list")
valid = {'running', 'terminated', 'archived', 'stopped'}
for i, s in enumerate(sessions):
if not isinstance(s, dict):
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] not a mapping")
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] not a mapping")
if not s.get('name') or not s.get('status'):
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] missing name/status")
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] missing name/status")
if s.get('role') is not None and (not isinstance(s['role'], str) or not s['role'].strip()):
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} role must be a non-empty string")
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} role must be a non-empty string")
if s['status'] not in valid:
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
raise SystemExit(f"VALIDATE: herdr_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")
raise SystemExit(f"VALIDATE: herdr_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")
raise SystemExit(f"VALIDATE: herdr_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')}
return {s.get('name'): s.get('status') for s in d.get('herdr_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')}
def get_all_sessions_status(d):
import hashlib
res = {}
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
name = s.get('name')
ser = json.dumps(s, sort_keys=True)
res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest()
@@ -484,7 +569,7 @@ try:
else:
d = {}
# Assemble d['tmux_sessions'] from sessions table if table contains data
# Assemble d['herdr_sessions'] from sessions table if table contains data
db_sessions = []
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
for s_row in cursor.fetchall():
@@ -497,9 +582,9 @@ try:
db_sessions.append(s_data)
if db_sessions:
d['tmux_sessions'] = db_sessions
elif 'tmux_sessions' not in d:
d['tmux_sessions'] = []
d['herdr_sessions'] = db_sessions
elif 'herdr_sessions' not in d:
d['herdr_sessions'] = []
old_sessions = get_all_sessions_status(d)
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
@@ -508,7 +593,7 @@ try:
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
# Role immutability check
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
name = s.get('name')
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}")
@@ -516,7 +601,7 @@ try:
# 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', []):
for s in d.get('herdr_sessions', []):
if s.get('status') == 'running':
s_name = s.get('name')
for k in running_keys:
@@ -529,11 +614,11 @@ try:
_validate(d)
# Separate globals and sessions for normalization
d_state = {k: v for k, v in d.items() if k != 'tmux_sessions'}
d_state = {k: v for k, v in d.items() if k != 'herdr_sessions'}
conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),))
current_names = []
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
name = s.get('name')
status = s.get('status')
pane_cwd = (s.get('pane') or {}).get('cwd', '')
@@ -599,7 +684,7 @@ PYEOF
# Workspace-SCOPED resolution of the resume UUID (P0-C). It NEVER returns a
# global agent_identities id unless that id's project_cwd matches THIS
# workspace. Resolution order:
# 1) tmux_sessions[] row whose pane.cwd == this workspace -> per-row own id
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
# (claude_session_id_own / agy_conversation_id_own)
# 2) on-disk scan scoped to this workspace
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
@@ -668,7 +753,7 @@ except Exception:
d = {}
running_ids = set()
for s_item in d.get('tmux_sessions', []):
for s_item in d.get('herdr_sessions', []):
if s_item.get('status') == 'running':
if target and s_item.get('name') == target:
continue
@@ -687,7 +772,7 @@ def emit(u):
# Fetch all sessions matching this workspace
sessions = []
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
sessions.append(s)
@@ -1013,7 +1098,7 @@ try:
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name and s.get('status') == 'stopped':
print(f"stopped_at={s.get('stopped_at', '?')}")
raise SystemExit(0)
@@ -1022,7 +1107,7 @@ try:
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name and s.get('status') == 'stopped':
print(f"stopped_at={s.get('stopped_at', '?')}")
raise SystemExit(0)
@@ -1139,9 +1224,9 @@ start_watchdog() {
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
wait_for_tui_ready() {
local sess="$1" agent="$2"
local local_tmux="tmux" i
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local_tmux="tmux -L $TMUX_SERVER_NAME"
local local_herdr="herdr" i
if [ -n "${HERDR_SERVER_NAME:-}" ] && [ "$HERDR_SERVER_NAME" != "default" ]; then
local_herdr="herdr -L $HERDR_SERVER_NAME"
fi
for i in {1..30}; do
@@ -1150,7 +1235,7 @@ wait_for_tui_ready() {
continue
fi
local content
content=$($local_tmux capture-pane -p -t "$sess" 2>/dev/null || echo "")
content=$($local_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then
case "$agent" in
claude)
@@ -1186,7 +1271,7 @@ wait_for_tui_ready() {
}
# inject_instructions <session_name> <instructions> [job_id]
# Injects instructions into the tmux session using paste-buffer and C-m.
# Injects instructions into the herdr session using paste-buffer and C-m.
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
inject_instructions() {
send_keys_safe "$1" "$2" "${3:-onboard}"
@@ -1200,23 +1285,23 @@ inject_instructions() {
# Callers MUST handle non-zero.
# ---------------------------------------------------------------------------
# Server-aware tmux (same isolation rule as inject_instructions).
_sks_tmux() {
mam_tmux "$@"
# Server-aware herdr (same isolation rule as inject_instructions).
_sks_herdr() {
mam_herdr "$@"
}
_pane_capture() { _sks_tmux capture-pane -p -t "$1" 2>/dev/null || echo ""; }
_pane_capture() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; }
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
# _wait_session_gone <sess> [max_sec=5]
# Reactive loop to wait until a tmux session goes away (happy path returns early).
# Reactive loop to wait until a herdr session goes away (happy path returns early).
_wait_session_gone() {
local sess="$1" max="${2:-5}" i
for ((i = 0; i < max * 4; i++)); do
_sks_tmux has-session -t "$sess" 2>/dev/null || return 0
_sks_herdr has-session -t "$sess" 2>/dev/null || return 0
sleep 0.25
done
return 1
@@ -1263,7 +1348,7 @@ send_keys_safe() {
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
while _pane_dialog_open "$sess"; do
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
_sks_tmux send-keys -t "$sess" Escape
_sks_herdr send-keys -t "$sess" Escape
sleep 1
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
@@ -1273,11 +1358,11 @@ send_keys_safe() {
sleep 2
done
_sks_tmux set-buffer -b "sks_$job_id" "$text"
_sks_tmux paste-buffer -b "sks_$job_id" -t "$sess"
_sks_tmux delete-buffer -b "sks_$job_id" 2>/dev/null || true
_sks_herdr set-buffer -b "sks_$job_id" "$text"
_sks_herdr paste-buffer -b "sks_$job_id" -t "$sess"
_sks_herdr delete-buffer -b "sks_$job_id" 2>/dev/null || true
if [[ "$sess" =~ "agy" ]]; then
_sks_tmux send-keys -t "$sess" C-m
_sks_herdr send-keys -t "$sess" C-m
return 0
fi
sleep 0.5
@@ -1292,7 +1377,7 @@ send_keys_safe() {
for try in 1 2 3; do
pre_submit=$(_pane_capture "$sess")
_sks_tmux send-keys -t "$sess" C-m
_sks_herdr send-keys -t "$sess" C-m
sleep "$try"
local cur_content
cur_content=$(_pane_capture "$sess")
@@ -1321,13 +1406,13 @@ handle_startup_dialogs() {
while [ "$waited" -lt "$timeout" ]; do
pane=$(_pane_tail "$sess" 20)
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
_sks_tmux send-keys -t "$sess" Enter
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
_sks_tmux send-keys -t "$sess" Down
_sks_herdr send-keys -t "$sess" Down
sleep 0.3
_sks_tmux send-keys -t "$sess" Enter
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
_sks_tmux send-keys -t "$sess" Enter
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then
return 0
fi
@@ -1338,4 +1423,4 @@ handle_startup_dialogs() {
}
# Auto-initialize the herdr translation shim wrapper on library source
_init_tmux_isolation
_init_herdr_isolation
+47 -47
View File
@@ -1,28 +1,28 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated tmux session for context-preserving long-running work. Always creates a tmux session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, session]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, context, session]
related_skills: [multi-agent-mux-resume, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
prereq_skills: [claude-code]
---
# Multi-Agent Create — Start a Fresh Agent in a tmux Session
# Multi-Agent Create — Start a Fresh Agent in a herdr Session
> **Companion skills**: `multi-agent-mux-resume` (resume an existing UUID), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live status).
> **Single source of truth**: `./.mam/agent-sessions.yaml` (this skill writes to it; never read it ad-hoc — go through this skill).
## What this skill does
Spawn a new agent (`claude` or `agy`/antigravity-cli) in a **dedicated tmux session** for context-preserving long-running work. The tmux session is the *container*; the agent's session ID is *data* inside the container. **This skill creates the container + starts the agent — but does not resume an old conversation** (use `multi-agent-mux-resume` for that).
Spawn a new agent (`claude` or `agy`/antigravity-cli) in a **dedicated herdr session** for context-preserving long-running work. The herdr session is the *container*; the agent's session ID is *data* inside the container. **This skill creates the container + starts the agent — but does not resume an old conversation** (use `multi-agent-mux-resume` for that).
For all agents: the tmux session name is produced by **`lib.sh::derive_session_name`** — the single source of truth shared by create/resume/stop/status/monitor (P0-A). The rule (verbatim from the function):
For all agents: the herdr session name is produced by **`lib.sh::derive_session_name`** — the single source of truth shared by create/resume/stop/status/monitor (P0-A). The rule (verbatim from the function):
> slug = the **two trailing path components** of the absolute workspace, `_`→`-`, lowercased, joined with `-`; name = `<slug>-creator-<agent>`.
@@ -33,9 +33,9 @@ So `$WORKSPACE_ROOT/landing_page/refer_landing_page` + `claude` → `landing-pag
Before doing anything, verify the environment:
```bash
# 1) tmux available and isolated server status
command -v tmux || { echo "ERROR: tmux not installed"; exit 1; }
echo "Tmux server name: ${TMUX_SERVER_NAME:-default}"
# 1) herdr available and isolated server status
command -v herdr || { echo "ERROR: herdr not installed"; exit 1; }
echo "Herdr server name: ${HERDR_SERVER_NAME:-default}"
# 2) claude / agy available
command -v claude # required for --agent claude
@@ -52,29 +52,29 @@ If any check fails → `kanban_block(reason="...")` (worker path) or report to u
## Standard names
- **tmux session name**: `derive_session_name <workspace> <agent>` (lib.sh)
- **herdr session name**: `derive_session_name <workspace> <agent>` (lib.sh)
- `<workspace-slug>` = `basename $(dirname $WORKSPACE)` `-` `basename $WORKSPACE` (lowercase, `_``-`)
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
- never re-derive this by hand — source lib.sh and call the function
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
- contents: tmux new-session with `claude` inside, auto-handles trust/bypass dialogs
- contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs
- see `<workdir>/agent_sessions.md` for the canonical wrapper template
## Tmux Server Isolation (격리 서버)
## Herdr Server Isolation (격리 서버)
When running multiple agent sessions alongside other workflows (e.g., cmux, Kanban workers, manual tmux sessions), sharing the default tmux server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
When running multiple agent sessions alongside other workflows (e.g., cmux, Kanban workers, manual herdr sessions), sharing the default herdr server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
To prevent this, you can run this skill inside an **isolated tmux server** using the `TMUX_SERVER_NAME` environment variable or the `--tmux-server <name>` flag (opt-in).
To prevent this, you can run this skill inside an **isolated herdr server** using the `HERDR_SERVER_NAME` environment variable or the `--herdr-server <name>` flag (opt-in).
### How to use
1. **Via Environment Variable**:
```bash
export TMUX_SERVER_NAME=multi-agent-canary
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' tmux server.
export HERDR_SERVER_NAME=multi-agent-canary
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' herdr server.
```
2. **Via Option Flag**:
```bash
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --tmux-server multi-agent-canary
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --herdr-server multi-agent-canary
```
3. **Submit Job Integration**:
You can automatically register a delegated job with a prompt when creating a session:
@@ -90,13 +90,13 @@ To prevent this, you can run this skill inside an **isolated tmux server** using
### Recommended Alias
You can set an alias in your shell to easily query sessions on the isolated server:
```bash
alias tmc='tmux -L multi-agent-canary'
alias tmc='herdr -L multi-agent-canary'
tmc ls # Lists only your multi-agent sessions
```
### Safety Rules (Pitfall 29 Summary)
- Never use global server termination commands like `tmux kill-server` or `tmux kill-session -a` as they will destroy all sessions on that server (including your own workspace sessions if they share the server).
- By using an isolated server via `TMUX_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
- Never use global server termination commands like `herdr kill-server` or `herdr kill-session -a` as they will destroy all sessions on that server (including your own workspace sessions if they share the server).
- By using an isolated server via `HERDR_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
## Workflow
@@ -107,25 +107,25 @@ source .agents/skills/lib.sh
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
# 1. If session already alive, fail fast
tmux has-session -t "$SESSION_NAME" 2>/dev/null && {
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach or multi-agent-mux-stop first."
herdr has-session -t "$SESSION_NAME" 2>/dev/null && {
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach or multi-agent-mux-stop first."
exit 1
}
# 2. Spawn the tmux session with the agent inside
# 2. Spawn the herdr session with the agent inside
case "$AGENT" in
claude)
# Use the wrapper if it exists, else inline tmux new-session
# Use the wrapper if it exists, else inline herdr new-session
# Use the wrapper if it exists (LOCAL_BIN env var overrides default $HOME/.local/bin)
local_bin="${LOCAL_BIN:-$HOME/.local/bin}"
if [ -x "$local_bin/$SESSION_NAME" ]; then
nohup "$local_bin/$SESSION_NAME" >/dev/null 2>&1 &
else
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
fi
;;
agy)
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
;;
*) echo "ERROR: --agent must be claude or agy, got: $AGENT"; exit 2 ;;
esac
@@ -134,22 +134,22 @@ esac
sleep 6
# 4. Capture pane metadata
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
PANE_CWD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
PANE_CMD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
TMUX_EPOCH=$(tmux list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1)
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
PANE_CWD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
PANE_CMD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
HERDR_EPOCH=$(herdr list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1)
```
## Registering the session in agent-sessions.yaml
After spawn, append a new `tmux_sessions[]` entry to `.mam/agent-sessions.yaml`:
After spawn, append a new `herdr_sessions[]` entry to `.mam/agent-sessions.yaml`:
```yaml
- name: <SESSION_NAME>
status: running
tmux_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
tmux_session_epoch: <TMUX_EPOCH>
tmux_server: <TMUX_SERVER_NAME> # Isolated server name (default: 'default')
herdr_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
herdr_session_epoch: <HERDR_EPOCH>
herdr_server: <HERDR_SERVER_NAME> # Isolated server name (default: 'default')
pane:
index: 0
pid: <PANE_PID>
@@ -162,9 +162,9 @@ After spawn, append a new `tmux_sessions[]` entry to `.mam/agent-sessions.yaml`:
plan: <from TUI status>
account: <from TUI status>
version: <from TUI status>
start_command: <the exact tmux new-session command used>
attach_command: "tmux attach -t <SESSION_NAME>"
kill_command: "tmux kill-session -t <SESSION_NAME>"
start_command: <the exact herdr new-session command used>
attach_command: "herdr attach -t <SESSION_NAME>"
kill_command: "herdr kill-session -t <SESSION_NAME>"
```
`cmd_full` per agent (this is the actual command line in the pane, not the resume command):
@@ -185,10 +185,10 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
## Pitfalls
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside tmux. The whole point of this skill is *the tmux session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the tmux session via `tmux new-session -d`).
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
- **Don't trust `--session-id <uuid>` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the tmux behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in tmux `cwd` — relative paths break when tmux respawns in a different shell context.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
- **The first `claude` message generates the session id** — `multi-agent-mux-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-mux-resume` later.
## Verification
@@ -196,28 +196,28 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
After spawn + YAML append:
```bash
# 1. tmux session is alive
tmux has-session -t "$SESSION_NAME" && echo OK || echo MISSING
# 1. herdr session is alive
herdr has-session -t "$SESSION_NAME" && echo OK || echo MISSING
# 2. pane has the expected cmd + cwd
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
herdr list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
# 3. agent-sessions.yaml has the new entry
python3 -c "
import yaml
d = yaml.safe_load(open('.mam/agent-sessions.yaml'))
names = [s['name'] for s in d['tmux_sessions']]
names = [s['name'] for s in d['herdr_sessions']]
assert '$SESSION_NAME' in names, 'session not registered'
print('OK:', names)
"
# 4. Optional: check the TUI status via capture-pane
tmux capture-pane -t "$SESSION_NAME" -p -S -20 # TUI ready = agent banner visible, no dialog text
herdr capture-pane -t "$SESSION_NAME" -p -S -20 # TUI ready = agent banner visible, no dialog text
```
## When NOT to use this skill
- **Resuming an old conversation** → `multi-agent-mux-resume`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching to an existing session** → `tmux attach -t <name>` (no skill needed)
- **One-shot print mode (claude -p "...")** → no tmux needed; use `claude-code` skill's print mode
- **Just attaching to an existing session** → `herdr attach -t <name>` (no skill needed)
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
@@ -4,18 +4,18 @@
# bash create_session.sh --workspace <path> --agent <claude|agy> --role <role> [--session <name>] [--wrapper]
#
# 동작:
# 1) preflight: tmux/claude/agy 가용성, workspace 존재
# 2) tmux 세션 이름 결정 (--session 없으면 자동)
# 3) tmux 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
# 1) preflight: herdr/claude/agy 가용성, workspace 존재
# 2) herdr 세션 이름 결정 (--session 없으면 자동)
# 3) herdr 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
# 4) pane 메타 캡처 (pid, cmd, cwd)
# 5) agent-sessions.yaml 에 tmux_sessions[] 엔트리 append
# 5) agent-sessions.yaml 에 herdr_sessions[] 엔트리 append
# 6) 검증 출력
#
# Exit codes:
# 0 = success
# 1 = preflight failure
# 2 = invalid args
# 3 = tmux session already exists (use multi-agent-mux-resume or delete first)
# 3 = herdr session already exists (use multi-agent-mux-resume or delete first)
# 4 = agent-sessions.yaml append failure
set -euo pipefail
@@ -29,10 +29,10 @@ Options:
--workspace PATH project directory (required)
--agent AGENT claude | agy | hermes | cline (required)
--role ROLE assigned role (required)
--session NAME tmux session name (default: derived from workspace)
--session NAME herdr session name (default: derived from workspace)
--wrapper force use of ~/.local/bin/<session> wrapper even if not present
--dry-run print commands without executing
--tmux-server NAME specify isolated tmux server name
--herdr-server NAME specify isolated herdr 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
--no-isolate disable state isolation (shares global configuration/history)
@@ -47,7 +47,7 @@ ROLE=""
SESSION_NAME=""
USE_WRAPPER=0
DRY_RUN=0
TMUX_SERVER_OPT=""
HERDR_SERVER_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=0
ISOLATE=1
@@ -60,7 +60,7 @@ while [ $# -gt 0 ]; do
--session) SESSION_NAME="$2"; shift 2 ;;
--wrapper) USE_WRAPPER=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;;
--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--isolate) ISOLATE=1; shift ;; # legacy compatibility
@@ -70,8 +70,8 @@ while [ $# -gt 0 ]; do
esac
done
if [ -n "$TMUX_SERVER_OPT" ]; then
export TMUX_SERVER_NAME="$TMUX_SERVER_OPT"
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SERVER_NAME="$HERDR_SERVER_OPT"
fi
# Preflight
@@ -114,8 +114,8 @@ if [ -z "$SESSION_NAME" ]; then
fi
# 이미 살아있으면 실패
if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
if _herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
exit 3
fi
@@ -139,7 +139,7 @@ if [ "$ISOLATE" = "1" ]; then
fi
fi
# tmux 세션 띄우기
# herdr 세션 띄우기
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
@@ -152,7 +152,7 @@ if [ -n "$ISOLATION_ROOT" ]; then
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
fi
# Resolve absolute path of the agent command to prevent tmux PATH inheritance issues (especially on macOS)
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
RESOLVED_BIN="$AGENT"
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
@@ -184,29 +184,29 @@ spawn() {
nohup "$WRAPPER" >/dev/null 2>&1 &
disown
else
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
_herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
fi
;;
agy|hermes|cline)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
_herdr 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
}
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would spawn: tmux session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT)"
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT)"
exit 0
fi
spawn
# Trap for rolling back/cleaning up tmux session if script exits due to error
cleanup_tmux_on_error() {
# Trap for rolling back/cleaning up herdr session if script exits due to error
cleanup_herdr_on_error() {
local exit_code=$?
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
echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
case "$ISOLATION_ROOT" in
@@ -215,7 +215,7 @@ cleanup_tmux_on_error() {
fi
fi
}
trap cleanup_tmux_on_error EXIT
trap cleanup_herdr_on_error EXIT
# TUI 준비 대기
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
@@ -224,14 +224,14 @@ if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
fi
# pane 메타 캡처
PANE_PID=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
PANE_CWD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
TMUX_EPOCH=$(date +%s)
PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
HERDR_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4)
START_CMD="herdr agent start \"$SESSION_NAME\" --workspace \"${TMUX_SERVER_NAME:-default}\" --cwd \"$WORKSPACE\" -- $CMD_FULL"
START_CMD="herdr agent start \"$SESSION_NAME\" --workspace \"${HERDR_SERVER_NAME:-default}\" --cwd \"$WORKSPACE\" -- $CMD_FULL"
# If --onboard is specified, automatically build the onboarding prompt
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
@@ -255,14 +255,14 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
else
delegate_agent="antigravity-cli"
fi
agent_session="tmux:$SESSION_NAME"
agent_session="herdr:$SESSION_NAME"
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
echo "Submitted delegated job: $DELEGATE_JOB_ID"
fi
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
mkdir -p "$(dirname "$AGENT_SESSIONS_YAML")"
echo "tmux_sessions: []" > "$AGENT_SESSIONS_YAML"
echo "herdr_sessions: []" > "$AGENT_SESSIONS_YAML"
fi
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
@@ -276,9 +276,9 @@ fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
HERDR_EPOCH="$HERDR_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}" \
HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-default}" \
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'
@@ -286,11 +286,11 @@ name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
role = os.environ['ROLE']
pid = os.environ.get('PANE_PID', '')
epoch = os.environ.get('TMUX_EPOCH', '')
server_name = os.environ.get('TMUX_SERVER_NAME', 'default')
epoch = os.environ.get('HERDR_EPOCH', '')
server_name = os.environ.get('HERDR_SERVER_NAME', 'default')
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
sessions = d.setdefault('tmux_sessions', [])
sessions = d.setdefault('herdr_sessions', [])
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
@@ -304,9 +304,9 @@ entry = {
'name': name,
'status': 'running',
'role': role,
'tmux_session_created_at': os.environ['NOW_ISO'],
'tmux_session_epoch': int(epoch) if epoch.isdigit() else 0,
'tmux_server': server_name,
'herdr_session_created_at': os.environ['NOW_ISO'],
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
'herdr_server': server_name,
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
'pane': {
'index': 0,
@@ -373,7 +373,7 @@ PYEOF
echo
echo "=== created ==="
echo "tmux session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
if [ -n "$DELEGATE_JOB_ID" ]; then
echo "delegate job: $DELEGATE_JOB_ID"
@@ -389,7 +389,7 @@ On failure run: $pub --event error --detail '<one-line reason>'.
Task: $SUBMIT_JOB_PROMPT"
# Inject instructions into the tmux pane
# Inject instructions into the herdr pane
rc=0
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
if [ "$rc" -ne 0 ]; then
@@ -45,7 +45,7 @@ multi-agent-mux-delegate-job submit \
### 신규 옵션 상세:
* `--type`: 작업 위임 타입을 지정합니다. (`direct`, `loop`, `discuss`)
* `--reviewer`: 리뷰를 담당할 에이전트 이름입니다 (기본값: `hermes`).
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 tmux 세션 이름입니다 (기본값: `tmux:hermes`).
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 herdr 세션 이름입니다 (기본값: `herdr:hermes`).
* `--max-iterations`: 루프 또는 토론의 최대 반복 횟수입니다 (기본값: `5`).
---
@@ -73,10 +73,10 @@ stateDiagram-v2
### 단계별 상세 동작 프로토콜:
1. **작업자(Worker) 실행**:
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `tmux:claude`)으로 설정하여 전달합니다.
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `herdr:claude`)으로 설정하여 전달합니다.
* 작업자가 수행을 완료하고 `completed` 이벤트를 발행하면 오케스트레이터가 이를 가로챕니다.
2. **리뷰어(Reviewer)로 스위칭**:
* 오케스트레이터는 전체 작업을 종료하지 않고, 작업 레코드의 `agent_session`을 리뷰어 세션(예: `tmux:hermes`)으로 변경합니다.
* 오케스트레이터는 전체 작업을 종료하지 않고, 작업 레코드의 `agent_session`을 리뷰어 세션(예: `herdr:hermes`)으로 변경합니다.
* 리뷰어에게 전달할 프롬프트를 자동으로 조립합니다:
> *"Review the changes/artifacts generated for job $JOB_ID. Check if they meet the requirements. If correct, publish completed event with 'PASS'. If there are issues, publish error event with detailed feedback/nits."*
* 상태를 다시 `pending`으로 리셋하여 리뷰어 세션이 잡을 집어갈 수 있도록 합니다.
@@ -28,18 +28,18 @@ This skill allows any agent (`claude-code`, `hermes`, `agy`, `cline`, etc.) to p
The `multi-agent-mux-delegate-job` bash wrapper handles job registration, subscriber management, agent session targeting, and validation hooks:
```bash
# 1) Submit a new job to a targeted agent session (e.g. tmux session name 'demo')
# 1) Submit a new job to a targeted agent session (e.g. herdr session name 'demo')
multi-agent-mux-delegate-job submit \
--agent <claude-code|hermes-agent|agy-agent|cline-agent|human> \
--agent-session tmux:<session_name> \
--agent-session herdr:<session_name> \
--prompt "Task description or instructions here" \
--role <Worker|Planner|Reviewer> \
--timeout 3600 --idle-timeout 120
# 2) Submit a job with a feedback loop (Worker-Reviewer Loop)
multi-agent-mux-delegate-job submit \
--agent <worker_agent> --agent-session tmux:<worker_session> \
--type loop --reviewer <reviewer_agent> --reviewer-session tmux:<reviewer_session> \
--agent <worker_agent> --agent-session herdr:<worker_session> \
--type loop --reviewer <reviewer_agent> --reviewer-session herdr:<reviewer_session> \
--prompt "Task description"
# 3) Check job status and audit logs
@@ -2,7 +2,7 @@
The registry is the **single source of truth** for delegated work. Job metadata
(id, prompt, broker, status, timeouts) lives in files, **not** environment
variables — so one tmux session can handle many jobs sequentially or in
variables — so one herdr session can handle many jobs sequentially or in
parallel without collisions, and `publish_event.py` / `job_subscriber.py` can
reconstruct everything they need from the registry alone.
@@ -37,7 +37,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
"updated_at": "2026-06-19T09:32:00Z",
"prompt": "정렬 문제 10개를 만들어 sort_problems.md로 저장…",
"agent": "claude-code",
"agent_session": "tmux:claude",
"agent_session": "herdr:claude",
"broker": {
"host": "broker.hivemq.com",
"port": 1883,
@@ -69,7 +69,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
Every read-modify-write (`register_job`, `pick_pending`, `update_status`,
`next_seq`) runs inside `registry_lock(registry_dir)`, an exclusive
`fcntl.flock` over `.lock`. Single-host, good enough for many tmux sessions on
`fcntl.flock` over `.lock`. Single-host, good enough for many herdr sessions on
one machine.
### Production — SQLite WAL
@@ -83,8 +83,8 @@ signatures stay identical; only the storage backend changes.
## 4. How multiple sessions take only their own work
Each tmux session carries an `agent_session` label (`tmux:claude`,
`tmux:claude-a`, `tmux:claude-b`, …). `pick_pending(agent_session)`:
Each herdr session carries an `agent_session` label (`herdr:claude`,
`herdr:claude-a`, `herdr:claude-b`, …). `pick_pending(agent_session)`:
1. acquires the registry lock,
2. scans for the **oldest** record with `status == "pending"` **and**
@@ -99,7 +99,7 @@ the job already `running` and moves on.
```bash
# session A only ever runs its own pending jobs
PY scripts/registry.py pick --agent-session tmux:claude-a # prints id or exits 3
PY scripts/registry.py pick --agent-session herdr:claude-a # prints id or exits 3
```
---
@@ -127,12 +127,12 @@ SQLite transaction when you migrate.
```bash
PY=.venv/bin/python
$PY scripts/registry.py register --prompt "…" --agent claude-code \
--agent-session tmux:claude --timeout 3600 --idle-timeout 120 # → prints job_id
--agent-session herdr:claude --timeout 3600 --idle-timeout 120 # → prints job_id
$PY scripts/registry.py list # human table
$PY scripts/registry.py list --json # full records
$PY scripts/registry.py get --job <id> # one record
$PY scripts/registry.py status --job <id> --set completed # set status
$PY scripts/registry.py pick --agent-session tmux:claude # claim → running
$PY scripts/registry.py pick --agent-session herdr:claude # claim → running
```
Exit codes: `0` ok, `1` not found / bad status, `3` (`pick`) no pending job for
@@ -266,7 +266,7 @@ def _lock_path(registry_dir: str) -> Path:
def registry_lock(registry_dir: str):
"""Advisory exclusive lock over the whole registry dir via fcntl.
PoC-grade single-host concurrency control. Multiple tmux sessions / scripts
PoC-grade single-host concurrency control. Multiple herdr sessions / scripts
serialise their read-modify-write of job records through this lock so two
sessions never claim the same pending job. For multi-host delegation move
to SQLite WAL (see references/registry.md)."""
@@ -16,7 +16,7 @@ Exit codes:
Usage:
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
publish_event.py --pick-pending --agent-session tmux:claude --event completed
publish_event.py --pick-pending --agent-session herdr:claude --event completed
publish_event.py --job <id> --event completed --retained
"""
from __future__ import annotations
@@ -135,7 +135,7 @@ def main(argv=None) -> int:
target.add_argument("--job", help="job id to publish for")
target.add_argument("--pick-pending", action="store_true",
help="auto-select a pending job for --agent-session")
parser.add_argument("--agent-session", default="tmux:claude",
parser.add_argument("--agent-session", default="herdr:claude",
help="session label used with --pick-pending")
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
parser.add_argument("--detail", default="")
@@ -50,7 +50,7 @@ def generate_job_id(bits: int = 32) -> str:
def register_job(
prompt: str,
agent: str = "claude-code",
agent_session: str = "tmux:claude",
agent_session: str = "herdr:claude",
role: str = "Worker",
broker: Optional[Dict[str, Any]] = None,
timeout_sec: int = 3600,
@@ -116,7 +116,7 @@ def register_job(
def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
"""Claim the oldest ``pending`` job for ``agent_session``, flipping it to
``running`` atomically under the lock. Returns the job id, or None if no
pending job matches. This is how each tmux session takes only its own work
pending job matches. This is how each herdr session takes only its own work
without two sessions grabbing the same job."""
with registry_lock(registry_dir):
candidates = []
@@ -240,7 +240,7 @@ def _build_parser() -> argparse.ArgumentParser:
p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
p_reg.add_argument("--prompt", required=True)
p_reg.add_argument("--agent", default="claude-code")
p_reg.add_argument("--agent-session", default="tmux:claude")
p_reg.add_argument("--agent-session", default="herdr:claude")
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
p_reg.add_argument("--timeout", type=int, default=3600)
p_reg.add_argument("--idle-timeout", type=int, default=120)
@@ -275,7 +275,7 @@ def _build_parser() -> argparse.ArgumentParser:
p_feedback.add_argument("--job", required=True)
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
p_pick.add_argument("--agent-session", default="tmux:claude")
p_pick.add_argument("--agent-session", default="herdr:claude")
p_logs = sub.add_parser(
"logs",
+1 -1
View File
@@ -186,6 +186,6 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
- **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 tmux 세션으로 기동되어 (`status.sh` 기준 `alive``running`) 있어야 합니다.
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive``running`) 있어야 합니다.
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오.
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
@@ -158,7 +158,7 @@ resolve_all_reviewers() {
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
target_agent = os.environ.get('TARGET_AGENT')
reviewers = [s.get('name') for s in d.get('tmux_sessions', [])
reviewers = [s.get('name') for s in d.get('herdr_sessions', [])
if 'reviewer' in s.get('role', '') and s.get('name') != target_agent]
print(','.join(reviewers))
"
@@ -172,7 +172,7 @@ import os, json
name = os.environ.get('NAME')
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
agent = None
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
agent = s.get('agent') or s.get('pane', {}).get('cmd')
break
@@ -198,7 +198,7 @@ resolve_planner_session() {
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
planner = ''
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if 'planner' in s.get('role', ''):
planner = s.get('name')
break
@@ -234,7 +234,7 @@ if [ "$PLAN_MODE" = true ]; then
# Step 1.1: Request initial plan from Planner
log_info "Requesting initial implementation plan from Planner..."
PLAN_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$PLANNER_SESSION" \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
@@ -268,7 +268,7 @@ if [ "$PLAN_MODE" = true ]; then
# Creator critique job
DEBATE_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$TARGET_AGENT" \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
@@ -296,7 +296,7 @@ if [ "$PLAN_MODE" = true ]; then
log_info "Planner refining plan with Creator's feedback..."
REFINE_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$PLANNER_SESSION" \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
@@ -352,7 +352,7 @@ if [ -n "$CURRENT_PLAN" ]; then
fi
EXEC_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$TARGET_AGENT" \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
@@ -396,7 +396,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
if [ "${#REVIEWERS[@]}" -eq 0 ]; then
log_warn "No reviewers specified. Conducting Creator Self-Review..."
SELF_REV_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$TARGET_AGENT" \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Reviewer" \
@@ -440,7 +440,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
fi
REV_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$rev" \
--agent-session "herdr:$rev" \
--agent "$(resolve_agent_type "$rev")" \
--type "direct" \
--role "Reviewer" \
@@ -511,7 +511,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
log_warn "Feedback involves complex code modifications. Diverting to Planner to revise plan..."
REFINE_PLAN_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$PLANNER_SESSION" \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
@@ -540,7 +540,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
# Creator execution corrective job
CORRECT_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "tmux:$TARGET_AGENT" \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
+26 -26
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived Kanban worker that polls .mam/agent-sessions.yaml against the actual tmux/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Designed to be dispatched as a Kanban goal_mode task (--goal) so it keeps running until the user stops it."
description: "Run a long-lived Kanban worker that polls .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Designed to be dispatched as a Kanban goal_mode task (--goal) so it keeps running until the user stops it."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [kanban, terminal, tmux]
environments: [kanban, terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, monitor, kanban, observation, reconciliation]
tags: [agent, herdr, claude, antigravity, agy, monitor, kanban, observation, reconciliation]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-stop, kanban-orchestrator]
prereq_skills: [kanban-worker, multi-agent-mux-create]
---
@@ -23,16 +23,16 @@ metadata:
Dispatch a **Kanban worker** (in `goal_mode`) that:
1. Every ~30s polls the actual state of:
- `tmux ls` (which sessions are alive)
- `tmux list-panes -t <session> ...` (pane cmd, cwd, pid)
- `herdr ls` (which sessions are alive)
- `herdr list-panes -t <session> ...` (pane cmd, cwd, pid)
- `~/.claude/projects/<workspace-key>/*.jsonl` mtime + first-line sessionId
- `~/.gemini/antigravity-cli/cache/last_conversations.json` (agy workspace → conversation mapping)
- `~/.gemini/antigravity-cli/conversations/<uuid>.db` mtime (agy)
2. Compares the live state to `agent-sessions.yaml`
3. Detects 4 classes of drift:
- **yaml-only terminated/archived/stopped**: tmux dead, YAML says `terminated`, `archived`, or `stopped` → OK, left untouched (deliberate end states)
- **yaml-only running, tmux dead**: YAML says `running`, tmux is gone → mark `terminated` with timestamp
- **tmux-only running, not in YAML**: tmux session exists with `<workspace>-creator-*` naming but YAML doesn't know about it → register as a new entry
- **yaml-only terminated/archived/stopped**: herdr dead, YAML says `terminated`, `archived`, or `stopped` → OK, left untouched (deliberate end states)
- **yaml-only running, herdr dead**: YAML says `running`, herdr is gone → mark `terminated` with timestamp
- **herdr-only running, not in YAML**: herdr session exists with `<workspace>-creator-*` naming but YAML doesn't know about it → register as a new entry
- **stale UUID**: YAML has a UUID, but the on-disk artifact is gone → flag in comment
4. Writes a Kanban `kanban_comment` on every drift event with diff details
5. Heartbeat every 5 minutes
@@ -40,14 +40,14 @@ Dispatch a **Kanban worker** (in `goal_mode`) that:
## When to use
- You have multiple workspaces with tmux agent sessions and want a single source of truth
- You have multiple workspaces with herdr agent sessions and want a single source of truth
- You suspect YAML drift after a host reboot / crash
- You want a notification when a session id was just created (so you can record it before next restart)
- You're running multi-day work and want to know "what's actually running right now"
## When NOT to use
- One-off interactive session — just check `tmux ls` and read the YAML
- One-off interactive session — just check `herdr ls` and read the YAML
- A single, short session — overhead > benefit
- You don't have a Kanban dispatcher running
@@ -69,9 +69,9 @@ hermes kanban create \
You are the agent-sessions monitor. Every 30 seconds, do:
1. Read .mam/agent-sessions.yaml
2. Run `tmux ls` and `tmux list-panes -F 'session=#{session_name} pid=#{pane_pid} cmd=#{pane_current_command} cwd=#{pane_current_path}'`
3. For each session in the YAML, check the corresponding tmux state
4. For each tmux session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
2. Run `herdr ls` and `herdr list-panes -F 'session=#{session_name} pid=#{pane_pid} cmd=#{pane_current_command} cwd=#{pane_current_path}'`
3. For each session in the YAML, check the corresponding herdr state
4. For each herdr session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
5. For any drift, call `kanban_comment` with the diff
6. Sleep 30 seconds, then repeat
@@ -88,7 +88,7 @@ EOF
The worker calls this script every 30s. It:
1. Diffs YAML ↔ tmux ↔ disk artifacts
1. Diffs YAML ↔ herdr ↔ disk artifacts
2. Updates YAML if needed (only when changes are real, not on every poll — avoids spamming)
3. Emits a JSON diff to stdout that the worker turns into a `kanban_comment`
@@ -115,13 +115,13 @@ Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E —
The `status` and `last_visible_status` fields MUST be one of the following exact strings: `running`, `stopped`, `terminated`, `archived`.
Any unstructured comments or reasons for the status change should be placed in `last_visible_note` or `termination_mode`.
### A. tmux dead, YAML says running → auto-terminate
### A. herdr dead, YAML says running → auto-terminate
```
YAML: status=running, pane.pid=201132, cmd=claude
tmux: no session
herdr: no session
→ set status=terminated, terminated_at=<now>, termination_mode=auto-detected
→ comment: "lab-landing-page-creator-claude: tmux gone (was pane 201132, cmd claude). Marked terminated."
→ comment: "lab-landing-page-creator-claude: herdr gone (was pane 201132, cmd claude). Marked terminated."
```
**Skip-set**: the auto-terminate only fires for sessions whose status is `running`.
@@ -129,16 +129,16 @@ Rows already in a deliberate end state — `terminated`, `archived`, or **`stopp
(set by `multi-agent-mux-stop`) — are
left untouched. This is critical: a `stopped` row keeps its `resumable: true` and
captured `*_session_id_own`, so the monitor must **not** overwrite it with
`terminated ("auto-detected")` when its tmux is (expectedly) gone.
`terminated ("auto-detected")` when its herdr is (expectedly) gone.
### B. tmux alive, not in YAML → auto-register
### B. herdr alive, not in YAML → auto-register
```
tmux: session=lab-paper-pdf2md-creator-agy, pid=...,
herdr: session=lab-paper-pdf2md-creator-agy, pid=...,
cmd=agy, cwd=$WORKSPACE_ROOT/paper-pdf2md
YAML: no such session
→ register as new entry: status=running, last_visible_status=running, last_visible_note=auto-registered
→ comment: "lab-paper-pdf2md-creator-agy: tmux found but not in YAML. Auto-registered."
→ comment: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered."
```
### C. New session id materializes (claude first message sent)
@@ -166,7 +166,7 @@ disk: ~/.claude/projects/.../87dc548e-...jsonl: missing
- **Don't run the monitor without `--goal`** — without goal mode, a single turn will spawn, do one reconcile, and complete. Goal mode keeps the worker alive across many turns.
- **The 30s poll is a default** — workers may override if they detect heavy churn. A workspace with 5+ agent sessions should bump to 60s to avoid noise.
- **`kanban_comment` rate limits** — Kanban may throttle if you comment too fast. Coalesce: only comment when the diff is *new* (not the same drift on every poll). The script tracks a state file at `.cache/multi-agent-mux-monitor/<workspace>.state` in the workspace root for this (overridable via `AGENT_SESSIONS_STATE_DIR`).
- **Don't fight the user's explicit action** — if `multi-agent-mux-stop` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `tmux has-session` check.
- **Don't fight the user's explicit action** — if `multi-agent-mux-stop` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `herdr has-session` check.
- **The monitor should never modify the conversation artifacts** (jsonl, db) — only the YAML. If you see a stale UUID, comment about it but don't delete the file.
- **TUI capture-pane is expensive** — only capture when you need to update `last_visible_status`, not every poll.
@@ -194,15 +194,15 @@ If `$HERMES_KANBAN_TASK` card has any comment containing "stop" or "stop monitor
## Drift responses
- A. tmux dead + YAML running: auto-terminate YAML, comment
- B. tmux alive not in YAML: auto-register, comment
- A. herdr dead + YAML running: auto-terminate YAML, comment
- B. herdr alive not in YAML: auto-register, comment
- C. New session id from *.jsonl: update YAML, comment
- D. Stale UUID: comment only, no YAML change
## Hard rules
- Do NOT modify conversation artifacts (jsonl, db, brain/)
- Do NOT spawn/delete tmux sessions — that's the create/delete skills' job
- Do NOT spawn/delete herdr sessions — that's the create/delete skills' job
- Do NOT call multi-agent-mux-create or multi-agent-mux-stop — only the user initiates those
- Do NOT call `git commit` / `git push`
```
@@ -217,7 +217,7 @@ When using `--subscribe` with the default PoC public broker
event from a third party can terminate your agent session.
3. **Mitigation**: Use `--subscribe` only on private TLS-enabled brokers
(production mode). For PoC, prefer polling-based monitor (`--once` or
no `--subscribe`) which reads YAML/tmux state directly without MQTT.
no `--subscribe`) which reads YAML/herdr state directly without MQTT.
4. **HMAC verification**: Events are now verified via `verify_hmac()` in
`mqtt_common.py` (see FW-05). Ensure `auth_token` is set for each job
to enable signature validation — unauthenticated events will be dropped.
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
# YAML ↔ herdr ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
#
# Usage:
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
@@ -9,7 +9,7 @@
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
# multi-agent-mux-status 스킬이 이걸 재사용.
#
# 출력 (JSON): {timestamp, yaml_path, tmux_sessions_alive, tmux_confirmed, drifts, actions}
# 출력 (JSON): {timestamp, yaml_path, herdr_sessions_alive, herdr_confirmed, drifts, actions}
#
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
set -euo pipefail
@@ -106,7 +106,7 @@ import registry
# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
# YAML flock with schema-validate + .bak (review item 5). Marks matching running
# sessions terminated and kills their tmux (review item 3 behaviour preserved),
# sessions terminated and kills their herdr (review item 3 behaviour preserved),
# or aborts the write entirely when nothing matches. The untrusted MQTT job id /
# event arrive via env (MQTT_JID / MQTT_EVENT) — never spliced into source (P1-B).
_MUTATION = r'''
@@ -116,10 +116,10 @@ _jid = os.environ['MQTT_JID']
_event = os.environ['MQTT_EVENT']
_now = datetime.now(timezone.utc)
_changed = False
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
_name = s.get('name')
_srv = s.get('tmux_server') or 'default'
_srv = s.get('herdr_server') or 'default'
if _event == 'completed':
s['delegate_job_id'] = None
print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
@@ -129,7 +129,7 @@ for s in d.get('tmux_sessions', []):
s['terminated_at'] = _now.strftime('%Y-%m-%dT%H:%M:%SZ')
s['terminated_at_epoch'] = int(_now.timestamp())
s['termination_mode'] = 'auto-detected (MQTT ' + _event + ')'
_cmd = ['tmux'] + (['-L', _srv] if _srv != 'default' else []) + ['kill-session', '-t', _name]
_cmd = ['herdr'] + (['-L', _srv] if _srv != 'default' else []) + ['kill-session', '-t', _name]
subprocess.run(_cmd, capture_output=True)
print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv) + ' due to MQTT ' + _event, flush=True)
_changed = True
@@ -328,21 +328,21 @@ except NameError:
drifts = []
actions = []
# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
tmux_sessions = []
tmux_confirmed = True
# === 현재 herdr 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
herdr_sessions = []
herdr_confirmed = True
# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_SERVER_NAME 포함
# YAML 에 등록된 고유한 herdr_server 목록 수집 + 환경변수 HERDR_SERVER_NAME 포함
unique_servers = {'default'}
if 'TMUX_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['TMUX_SERVER_NAME'])
for s in d.get('tmux_sessions', []):
srv = s.get('tmux_server') or 'default'
if 'HERDR_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
for s in d.get('herdr_sessions', []):
srv = s.get('herdr_server') or 'default'
unique_servers.add(srv)
try:
for srv in sorted(unique_servers):
cmd = ['tmux']
cmd = ['herdr']
if srv != 'default':
cmd += ['-L', srv]
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
@@ -352,19 +352,19 @@ try:
if not line:
continue
name, created = line.split('|', 1)
tmux_sessions.append({'name': name, 'created': int(created), 'server': srv})
herdr_sessions.append({'name': name, 'created': int(created), 'server': srv})
else:
err = (r.stderr or '').lower()
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
if not is_empty:
tmux_confirmed = False
herdr_confirmed = False
except Exception:
tmux_confirmed = False
herdr_confirmed = False
def pane_meta(session, srv):
try:
cmd = ['tmux']
cmd = ['herdr']
if srv != 'default':
cmd += ['-L', srv]
cmd += ['list-panes', '-t', session, '-F',
@@ -376,35 +376,35 @@ def pane_meta(session, srv):
return None
yaml_sessions = d.get('tmux_sessions', [])
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 tmux_sessions}
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
# === drift A: tmux dead + YAML running → auto-terminate ===
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
if tmux_confirmed:
# === drift A: herdr dead + YAML running → auto-terminate ===
# herdr 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
if herdr_confirmed:
for s in yaml_sessions:
name = s.get('name')
if not name:
continue
# 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
# (없으면 tmux-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
if s.get('status') in ('terminated', 'archived', 'stopped'):
continue
srv = s.get('tmux_server') or 'default'
srv = s.get('herdr_server') or 'default'
if (name, srv) not in alive_set:
s['status'] = 'terminated'
s['terminated_at'] = now_iso
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
s['termination_mode'] = 'auto-detected (tmux gone)'
s['termination_mode'] = 'auto-detected (herdr gone)'
pane = s.get('pane') or {}
drifts.append({'class': 'A', 'name': name,
'msg': f"{name}: tmux gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
'msg': f"{name}: herdr gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
actions.append(f"terminated: {name}")
# === drift B: tmux alive + not in YAML → auto-register ===
if tmux_confirmed:
for t in tmux_sessions:
# === drift B: herdr alive + not in YAML → auto-register ===
if herdr_confirmed:
for t in herdr_sessions:
name = t['name']
if name in yaml_session_names:
continue
@@ -434,14 +434,14 @@ if tmux_confirmed:
entry = {
'name': name,
'status': 'running',
'tmux_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'tmux_session_epoch': t['created'],
'tmux_server': srv,
'herdr_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'herdr_session_epoch': t['created'],
'herdr_server': srv,
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
# P2: cwd 인용
'start_command': f'tmux {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'tmux {server_opt}attach -t {name}',
'kill_command': f'tmux {server_opt}kill-session -t {name}',
'start_command': f'herdr {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'herdr {server_opt}attach -t {name}',
'kill_command': f'herdr {server_opt}kill-session -t {name}',
'last_visible_status': 'running',
'last_visible_note': 'auto-registered by monitor',
}
@@ -465,14 +465,14 @@ if tmux_confirmed:
elif agent == 'cline':
entry['child_pid'] = 0
entry['cline_conversation_id_own'] = None
d.setdefault('tmux_sessions', []).append(entry)
d.setdefault('herdr_sessions', []).append(entry)
yaml_session_names.add(name)
drifts.append({'class': 'B', 'name': name,
'msg': f"{name}: tmux found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
actions.append(f"registered: {name}")
# === drift C: claude 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-claude'):
continue
if s.get('status') != 'running':
@@ -507,7 +507,7 @@ for s in d.get('tmux_sessions', []):
actions.append(f"updated session id: {sid}")
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-agy'):
continue
if s.get('status') != 'running':
@@ -531,7 +531,7 @@ for s in d.get('tmux_sessions', []):
pass
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-hermes'):
continue
if s.get('status') != 'running':
@@ -556,7 +556,7 @@ for s in d.get('tmux_sessions', []):
pass
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-cline'):
continue
if s.get('status') != 'running':
@@ -630,8 +630,8 @@ if cn.get('session_id'):
result = {
'timestamp': now_iso,
'yaml_path': yaml_path,
'tmux_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in tmux_sessions),
'tmux_confirmed': tmux_confirmed,
'herdr_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in herdr_sessions),
'herdr_confirmed': herdr_confirmed,
'drifts': drifts,
'actions': actions,
}
+25 -25
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a tmux session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a tmux session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose tmux died but the agent's conversation is still on disk."
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, resume, session-id]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, context, resume, session-id]
related_skills: [multi-agent-mux-create, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
prereq_skills: [multi-agent-mux-create]
---
@@ -16,18 +16,18 @@ metadata:
# Multi-Agent Resume — Reattach to a Saved Conversation
> **Companion skills**: `multi-agent-mux-create` (start a fresh agent), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live status).
> **Tmux Isolation**: `TMUX_SERVER_NAME` env var를 create에서 설정한 경우, 동일 서버에서 동작합니다. 자세한 격리 패턴은 [multi-agent-mux-create/SKILL.md](../multi-agent-mux-create/SKILL.md) 참조.
> **Herdr Isolation**: `HERDR_SERVER_NAME` env var를 create에서 설정한 경우, 동일 서버에서 동작합니다. 자세한 격리 패턴은 [multi-agent-mux-create/SKILL.md](../multi-agent-mux-create/SKILL.md) 참조.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
**Container + data reconstruction**: spawn a tmux session (the container), then run the agent inside with a specific session id (the data) so the previous conversation's context is restored.
**Container + data reconstruction**: spawn a herdr session (the container), then run the agent inside with a specific session id (the data) so the previous conversation's context is restored.
Three cases this skill handles:
1. **tmux is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the tmux session + run `claude -r <id>` / `agy --conversation <id>`.
2. **tmux is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
1. **herdr is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r <id>` / `agy --conversation <id>`.
2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **herdr is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
### Resuming a `stopped` session (`stopped → running`)
@@ -64,7 +64,7 @@ WORKSPACE=/path/to/project
AGENT=claude # or agy or hermes
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
# Resolve the isolated tmux server name & load isolation utils
# Resolve the isolated herdr server name & load isolation utils
source .agents/skills/lib.sh
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
@@ -76,12 +76,12 @@ if [ -z "$UUID" ]; then
exit 1
fi
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
export HERDR_SERVER_NAME="$(resolve_herdr_server "$SESSION_NAME")"
# 2. If tmux is alive, attach. Done.
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "tmux '$SESSION_NAME' already running. Attaching..."
exec tmux attach -t "$SESSION_NAME"
# 2. If herdr is alive, attach. Done.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "herdr '$SESSION_NAME' already running. Attaching..."
exec herdr attach -t "$SESSION_NAME"
fi
# 3. Resolve isolation settings for this session (T4/T5 re-apply)
@@ -108,7 +108,7 @@ try:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(json.dumps(s.get('isolation') or {}))
raise SystemExit(0)
@@ -138,20 +138,20 @@ 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)
# 4. Spawn new herdr session + run agent with the saved id (and re-applied isolation)
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\""
START_CMD="herdr 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\""
START_CMD="herdr 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\""
eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
;;
esac
@@ -161,7 +161,7 @@ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
--session "$SESSION_NAME" --uuid "$UUID"
# 5. Attach
tmux attach -t "$SESSION_NAME"
herdr attach -t "$SESSION_NAME"
```
## Pitfalls
@@ -175,21 +175,21 @@ tmux attach -t "$SESSION_NAME"
## Verification
```bash
# 1. tmux alive with the right cmd
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
# 1. herdr alive with the right cmd
herdr list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
# 2. agent-sessions.yaml updated
python3 -c "
import yaml
d = yaml.safe_load(open('.mam/agent-sessions.yaml'))
s = [s for s in d['tmux_sessions'] if s['name'] == '$SESSION_NAME'][0]
s = [s for s in d['herdr_sessions'] if s['name'] == '$SESSION_NAME'][0]
print(f' status: {s[\"status\"]}')
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
"
# 3. TUI shows resumed conversation (capture-pane to verify)
sleep 5
tmux capture-pane -t "$SESSION_NAME" -p -S -30
herdr capture-pane -t "$SESSION_NAME" -p -S -30
# look for the previous message at top of the buffer (claude) or last_visible_status set (agy)
```
@@ -197,4 +197,4 @@ tmux capture-pane -t "$SESSION_NAME" -p -S -30
- **No saved session yet** → `multi-agent-mux-create`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching** → `tmux attach -t <name>` (no skill needed)
- **Just attaching** → `herdr attach -t <name>` (no skill needed)
@@ -37,11 +37,11 @@ if [ -z "$UUID" ]; then
exit 1
fi
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
# 2. If tmux is alive, print warning or attach.
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "tmux '$SESSION_NAME' already running."
# 2. If herdr is alive, print warning or attach.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "herdr '$SESSION_NAME' already running."
# Just update YAML to make sure it's set to running
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
@@ -72,7 +72,7 @@ try:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(json.dumps(s.get('isolation') or {}))
raise SystemExit(0)
@@ -87,7 +87,7 @@ if [ -n "$ISO_ROOT" ]; then
echo "Re-applying isolation: root=$ISO_ROOT env=$ISO_ENV args=$ISO_ARGS"
fi
# Resolve absolute path of the agent command to prevent tmux PATH inheritance issues (especially on macOS)
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
RESOLVED_BIN="$AGENT"
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
@@ -120,9 +120,8 @@ if [ -n "$ISO_ARGS" ]; then
CMD_FULL="$CMD_FULL $ISO_ARGS"
fi
# 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
# 4. Spawn new agent session (delegates to herdr translation shim)
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
if [ "$AGENT" = "claude" ]; then
# auto-handle trust / bypass dialogs
handle_startup_dialogs "$SESSION_NAME" 20
@@ -33,7 +33,7 @@ done
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
if [ -z "$AGENT" ]; then
@@ -48,8 +48,8 @@ fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 새 tmux pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
# 새 herdr pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
PANE_PID="${PANE_PID:-}"
CHILD_PID=0
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then
@@ -61,7 +61,7 @@ DELEGATE_JOB_ID=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAM
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('delegate_job_id', '') or '')
sys.exit(0)
@@ -77,7 +77,7 @@ now = os.environ['NOW_ISO']
pane_pid = os.environ.get('PANE_PID', '')
target = None
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
target = s
break
+18 -18
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent tmux sessions — name, YAML status, tmux alive, pane cmd/cwd, resume UUID on disk, and any drift. No Kanban, no mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up a Kanban monitor worker."
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No Kanban, no mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up a Kanban monitor worker."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, status, read-only, snapshot]
tags: [agent, herdr, claude, antigravity, agy, status, read-only, snapshot]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-stop, multi-agent-mux-monitor]
prereq_skills: [multi-agent-mux-create, multi-agent-mux-monitor]
---
@@ -16,19 +16,19 @@ metadata:
# Multi-Agent Status — Read-Only Instant Snapshot
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live polling).
> **Tmux Isolation**: `status` 명령은 YAML에 등록된 모든 세션의 격리 서버(`tmux_server` 필드)를 자동으로 조회하여 상태를 확인하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
> **Herdr Isolation**: `status` 명령은 YAML에 등록된 모든 세션의 격리 서버(`herdr_server` 필드)를 자동으로 조회하여 상태를 확인하므로, `HERDR_SERVER_NAME` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
Print a single table of every agent tmux session, comparing YAML state to actual tmux state. **No mutation. No Kanban. No polling loop.**
Print a single table of every agent herdr session, comparing YAML state to actual herdr state. **No mutation. No Kanban. No polling loop.**
This is the "what's running right now?" answer — faster than dispatching `multi-agent-mux-monitor` (which polls every 30s) and safer than `reconcile.sh --once --emit-diff` (which mutates as a side effect).
## Pre-flight
```bash
command -v tmux
command -v herdr
command -v python3
test -f .mam/agent-sessions.yaml
```
@@ -45,19 +45,19 @@ The script:
1. Calls `reconcile.sh --once --emit-diff --dry-run` (read-only; no YAML mutation) for the drift snapshot
2. Loads `agent-sessions.yaml` (read-only) to enrich the table
3. For each row in `tmux_sessions[]`:
- tmux alive? (via `tmux has-session -t <name>`)
- pane cmd, cwd (via `tmux list-panes`)
3. For each row in `herdr_sessions[]`:
- herdr alive? (via `herdr has-session -t <name>`)
- pane cmd, cwd (via `herdr list-panes`)
- resume UUID on disk? (claude: `$CLAUDE_PROJECT_DIR/<key>/<uuid>.jsonl` with default `~/.claude/projects/`; agy: `$HOME_DIR/.gemini/antigravity-cli/conversations/<uuid>.db` with default `~/.gemini/...`)
4. For each tmux session matching `*-creator-*` not in YAML → flag as "unregistered"
4. For each herdr session matching `*-creator-*` not in YAML → flag as "unregistered"
5. Prints a table (default) or JSON (with `--json`)
## Output format (default = aligned table)
```
agent-sessions status — 2026-06-19T14:20:00Z (tmux_confirmed=True)
agent-sessions status — 2026-06-19T14:20:00Z (herdr_confirmed=True)
========================================================================================================================================
NAME SERVER YAML TMUX CMD RESUME JOB_ID JOB_STATUS DRIFT
NAME SERVER YAML HERDR CMD RESUME JOB_ID JOB_STATUS DRIFT
----------------------------------------------------------------------------------------------------------------------------------------
lab-landing-page-creator-claude default running alive claude yes - - -
lab-landing-page-creator-agy default terminated dead agy yes 5fe09ba8 completed -
@@ -70,13 +70,13 @@ lab-paper-pdf2md-creator-claude default running alive clau
```json
{
"yaml_path": "...",
"tmux_sessions_alive": ["..."],
"herdr_sessions_alive": ["..."],
"yaml_entries": [...],
"rows": [
{
"name": "lab-landing-page-creator-claude",
"yaml_status": "running",
"tmux_alive": true,
"herdr_alive": true,
"pane_cmd": "claude",
"pane_cwd": "/home/.../refer_landing_page",
"resume_uuid_on_disk": true,
@@ -85,7 +85,7 @@ lab-paper-pdf2md-creator-claude default running alive clau
{
"name": "lab-landing-page-creator-agy",
"yaml_status": "terminated",
"tmux_alive": false,
"herdr_alive": false,
"drift": "yaml-says-terminated-but-disk-uuid-still-present"
}
],
@@ -98,15 +98,15 @@ lab-paper-pdf2md-creator-claude default running alive clau
| Class | Detection | Meaning |
|---|---|---|
| `A` | YAML `running`, tmux dead | session died without going through `multi-agent-mux-stop`. *Could* auto-terminate but won't — that's `multi-agent-mux-monitor`'s job. |
| `B` | tmux alive, not in YAML | ad-hoc session someone started without `multi-agent-mux-create`. Suggest: "use multi-agent-mux-create to register, or tmux kill-session to clean up." |
| `A` | YAML `running`, herdr dead | session died without going through `multi-agent-mux-stop`. *Could* auto-terminate but won't — that's `multi-agent-mux-monitor`'s job. |
| `B` | herdr alive, not in YAML | ad-hoc session someone started without `multi-agent-mux-create`. Suggest: "use multi-agent-mux-create to register, or herdr kill-session to clean up." |
| `C` | YAML has `claude_session_id_own: null` AND a new *.jsonl exists | new session id materialized; suggest: "run multi-agent-mux-resume or reconcile to register it." |
| `D` | YAML has UUID in `agent_identities`, but the on-disk artifact is gone | stale UUID; user should `multi-agent-mux-stop --purge-conversation` to clean up. |
## Pitfalls
- **Do NOT use this skill to drive mutations** — the output is a snapshot, not a call to action. If you need to fix drifts, dispatch `multi-agent-mux-monitor` (Kanban worker) or run `multi-agent-mux-resume` / `multi-agent-mux-stop` manually.
- **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `tmux kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path.
- **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `herdr kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path.
- **If `agent-sessions.yaml` is malformed** — print the YAML error verbatim and exit 1. Do NOT attempt recovery (that's `multi-agent-mux-stop --purge-conversation` or manual edit's job).
- **Sessions outside the `<workspace>-creator-*` naming convention** are still shown but tagged `ad-hoc` — they didn't go through `multi-agent-mux-create` and aren't tracked in YAML.
@@ -25,7 +25,7 @@ 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/tmux_sessions_alive/tmux_confirmed/drifts/actions), not the enriched
# 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
@@ -42,7 +42,7 @@ try:
except Exception:
d = {}
alive = set(drift.get('tmux_sessions_alive', []))
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'])
@@ -91,9 +91,9 @@ def get_job_status(s):
sessions_detail = []
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
name = s.get('name', '?')
server = s.get('tmux_server') or 'default'
server = s.get('herdr_server') or 'default'
jid, jstatus = get_job_status(s)
pane = s.get('pane') or {}
sessions_detail.append({
@@ -103,7 +103,7 @@ for s in d.get('tmux_sessions', []):
'name': name,
'server': server,
'status': s.get('status', '?'),
'tmux_alive': f"{name}|{server}" in alive,
'herdr_alive': f"{name}|{server}" in alive,
'cmd': pane.get('cmd'),
'role': s.get('role'),
'resume_state': resume_on_disk(s),
@@ -138,7 +138,7 @@ try:
except Exception:
d = {}
alive = set(drift.get('tmux_sessions_alive', []))
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'])
@@ -188,8 +188,8 @@ def get_job_status(s):
return (jid, 'unknown')
sessions = d.get('tmux_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['tmux_confirmed']})")
sessions = d.get('herdr_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
print("=" * 136)
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print("-" * 136)
@@ -197,7 +197,7 @@ if not sessions:
print("(no sessions registered)")
for s in sessions:
name = s.get('name', '?')
server = s.get('tmux_server') or 'default'
server = s.get('herdr_server') or 'default'
status = s.get('status', '?')
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
cmd = (s.get('pane') or {}).get('cmd', '?')
+19 -19
View File
@@ -1,35 +1,35 @@
---
name: multi-agent-mux-stop
description: "Stop an agent tmux session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, stop, terminate, cleanup]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, stop, terminate, cleanup]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor]
prereq_skills: [multi-agent-mux-create, multi-agent-mux-resume]
---
# Multi-Agent Stop — Stop an Agent tmux Session
# Multi-Agent Stop — Stop an Agent herdr Session
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-monitor` (live status).
> **Tmux Isolation**: `stop` 명령은 YAML의 `tmux_server` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
> **Herdr Isolation**: `stop` 명령은 YAML의 `herdr_server` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `HERDR_SERVER_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
Stop an agent's tmux session gracefully, resolve and store the conversation ID, and **mark the YAML entry (status=stopped)**. Preserves:
Stop an agent's herdr session gracefully, resolve and store the conversation ID, and **mark the YAML entry (status=stopped)**. Preserves:
- The tmux session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same tmux spec
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same herdr spec
The stop command is always **graceful by default**:
1. Sends exit keys to the agent TUI (`/exit` for Claude, `Exit` for Agy) and waits 3 seconds.
2. If still alive, issues `tmux kill-session` (SIGTERM) and waits 5 seconds.
2. If still alive, issues `herdr kill-session` (SIGTERM) and waits 5 seconds.
3. If still alive, kills the pane PID via SIGKILL (`kill -9`) as a last resort.
4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing, ensuring the next resume uses a race-free tier-1 lookup.
@@ -43,7 +43,7 @@ AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
names = [s['name'] for s in d.get('tmux_sessions', [])]
names = [s['name'] for s in d.get('herdr_sessions', [])]
if '$SESSION_NAME' not in names:
print('NOT in YAML — refusing to stop (no audit trail). Use multi-agent-mux-create first, or pass --force-no-yaml.')
raise SystemExit(1)
@@ -53,7 +53,7 @@ if '$SESSION_NAME' not in names:
ALREADY=$(python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
s = [x for x in d['herdr_sessions'] if x['name']=='$SESSION_NAME'][0]
print(s.get('status', 'unknown'))
")
if [ "$ALREADY" = "stopped" ]; then
@@ -95,7 +95,7 @@ If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `termi
The script:
1. Verifies the session is in agent-sessions.yaml
2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry
3. Captures the `last_visible_status` from `tmux capture-pane` (so we have a final TUI snapshot for audit)
3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit)
4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback
5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy)
6. Updates the YAML entry and SQLite database atomically
@@ -104,21 +104,21 @@ The script:
## Pitfalls
- **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but tmux is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "tmux already dead, just update YAML" and proceed.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but herdr is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "herdr already dead, just update YAML" and proceed.
- **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-mux-create` and never sent a message, the entry has `claude_session_id_own: null`. Stopping must preserve that field.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `tmux ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own.
## Verification
```bash
# 1. tmux gone
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux gone"
# 1. herdr gone
herdr has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: herdr gone"
# 2. YAML has stopped entry
python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
s = [x for x in d['herdr_sessions'] if x['name']=='$SESSION_NAME'][0]
assert s['status'] == 'stopped', f'expected stopped, got {s[\"status\"]}'
assert s.get('stopped_at'), 'missing stopped_at'
print(f'OK: stopped at {s[\"stopped_at\"]}')
@@ -131,6 +131,6 @@ print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}
## When NOT to use this skill
- **Just detaching** → `tmux detach` (Ctrl-B d) or just close the terminal. The tmux session keeps running.
- **Stopping the agent inside but keeping tmux** → send `Ctrl-C` or `/exit` (claude) / `Ctrl-D` (agy) via `tmux send-keys`. The tmux session stays but the agent process is gone.
- **Just detaching** → `herdr detach` (Ctrl-B d) or just close the terminal. The herdr session keeps running.
- **Stopping the agent inside but keeping herdr** → send `Ctrl-C` or `/exit` (claude) / `Ctrl-D` (agy) via `herdr send-keys`. The herdr session stays but the agent process is gone.
- **Replacing an existing session with a new one** → `multi-agent-mux-stop` first, then `multi-agent-mux-create`.
@@ -5,9 +5,9 @@
# [--mode soft|hard] [--purge-conversation] [--yes]
#
# mode:
# soft — YAML 을 status=archived 로 마크, tmux 세션은 그대로 둠 (P1-A:
# terminated 는 tmux 가 실제로 죽은 상태에만 사용)
# hard — tmux kill-session + YAML status=terminated
# soft — YAML 을 status=archived 로 마크, herdr 세션은 그대로 둠 (P1-A:
# terminated 는 herdr 가 실제로 죽은 상태에만 사용)
# hard — herdr kill-session + YAML status=terminated
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
# agent_identities 를 참조하지 않음. resume 불가.
@@ -27,7 +27,7 @@
# Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (tmux session survived the kill chain)
# 4 = purge aborted (herdr session survived the kill chain)
set -euo pipefail
# shellcheck disable=SC1091
@@ -70,8 +70,8 @@ done
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
export TMUX_SERVER_NAME
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
export HERDR_SERVER_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
if [ -z "$AGENT" ]; then
@@ -90,7 +90,7 @@ MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" p
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
cwd = (s.get('pane') or {}).get('cwd', '')
jid = s.get('delegate_job_id', '') or ''
@@ -131,17 +131,17 @@ fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
NOW_EPOCH=$(date +%s)
# tmux 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
TMUX_ALIVE=0
# herdr 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
HERDR_ALIVE=0
LAST_STATUS=""
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
TMUX_ALIVE=1
LAST_STATUS=$(tmux capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
HERDR_ALIVE=1
LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
fi
# --capture-id: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때).
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -> tier-3(cache)
# 를 알아서 시도하므로 tmux 생사와 무관하게 동작.
# 를 알아서 시도하므로 herdr 생사와 무관하게 동작.
CAPTURED_UUID=""
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true)
@@ -157,7 +157,7 @@ delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
# --graceful: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL).
graceful_stop() {
local pane_pid exitkey
pane_pid=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
case "$AGENT" in
claude) exitkey="/exit" ;;
agy) exitkey="Exit" ;;
@@ -168,14 +168,14 @@ graceful_stop() {
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
_wait_session_gone "$SESSION_NAME" 5 || true
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "graceful: exited cleanly"
return 0
fi
echo "graceful: still alive → kill-session (SIGTERM)"
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
_wait_session_gone "$SESSION_NAME" 8 || true
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "graceful: terminated after kill-session"
return 0
fi
@@ -183,22 +183,22 @@ graceful_stop() {
[ -n "$pane_pid" ] && kill -9 "$pane_pid" 2>/dev/null || true
}
# tmux 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
if [ "$GRACEFUL" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
# herdr 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
if [ "$GRACEFUL" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
graceful_stop
elif [ "$TMUX_ALIVE" = "1" ]; then
tmux kill-session -t "$SESSION_NAME"
echo "killed tmux: $SESSION_NAME"
elif [ "$HERDR_ALIVE" = "1" ]; then
herdr kill-session -t "$SESSION_NAME"
echo "killed herdr: $SESSION_NAME"
else
echo "tmux already dead, just updating YAML"
echo "herdr already dead, just updating YAML"
fi
# Purge pre-gate: 레코드 제거는 tmux 사망이 확인된 경우에만 허용한다.
# Purge pre-gate: 레코드 제거는 herdr 사망이 확인된 경우에만 허용한다.
# (kill 체인은 best-effort — 세션이 살아남으면 monitor drift-B 가
# 레코드 없는 세션을 running 으로 자동 재등록해 purge 가 조용히 뒤집힌다)
if [ "$PURGE" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
_wait_session_gone "$SESSION_NAME" 5 || true # SIGKILL 폴백의 비동기 회수 윈도우 흡수
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
if herdr 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 (herdr session attach '$SESSION_NAME'), then re-run" >&2
@@ -226,7 +226,7 @@ reason = os.environ.get('REASON', '') or 'manual_stop'
captured = os.environ.get('CAPTURED_UUID', '').strip()
target = None
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
target = s
break
@@ -337,7 +337,7 @@ elif purge and not purge_uuid:
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
if purge:
d['tmux_sessions'] = [s for s in d.get('tmux_sessions', []) if s.get('name') != name]
d['herdr_sessions'] = [s for s in d.get('herdr_sessions', []) if s.get('name') != name]
if purge_uuid:
print(f"removed: {name} (registry entry fully purged from YAML+DB)", flush=True)
else: