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:
+176
-91
@@ -3,7 +3,7 @@
|
|||||||
#
|
#
|
||||||
# Single source of truth for the four things that were inconsistently
|
# Single source of truth for the four things that were inconsistently
|
||||||
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
|
# 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)
|
# - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B)
|
||||||
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
|
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
|
||||||
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
|
# - 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)"
|
WORKSPACE_ROOT="$(cd "$SKILL_DIR/../.." && pwd)"
|
||||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
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)
|
# 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_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'
|
_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}"
|
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).
|
# Paths to exclude when resolving the real herdr binary (shim/wrapper dirs).
|
||||||
_TMUX_SHIM_DIR_PATTERN="${_TMUX_SHIM_DIR_PATTERN:-/multi-agent-tmux-shim/}"
|
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
|
||||||
_TMUX_SKILLS_BIN_PATTERN="${_TMUX_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
|
_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() {
|
_resolve_real_herdr_path() {
|
||||||
_REAL_TMUX_PATH="tmux"
|
_REAL_HERDR_PATH="herdr"
|
||||||
export _REAL_TMUX_PATH
|
export _REAL_HERDR_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
_init_tmux_isolation() {
|
_init_herdr_isolation() {
|
||||||
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
||||||
mkdir -p "$wrapper_dir"
|
mkdir -p "$wrapper_dir"
|
||||||
cat <<'EOF' > "$wrapper_dir/tmux"
|
cat <<'EOF' > "$wrapper_dir/herdr"
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Herdr-to-Tmux translation shim wrapper
|
# Herdr-to-Herdr translation shim wrapper
|
||||||
set -euo pipefail
|
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)
|
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
cmd="${1:-}"
|
cmd="${1:-}"
|
||||||
if [ -z "$cmd" ]; then
|
if [ -z "$cmd" ]; then
|
||||||
echo "tmux shim: no command specified" >&2
|
echo "herdr shim: no command specified" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
@@ -73,7 +98,7 @@ case "$cmd" in
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
herdr agent get "$sess" >/dev/null 2>&1
|
"$REAL_HERDR" agent get "$sess" >/dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
new-session)
|
new-session)
|
||||||
name="" ws="" run_cmd=""
|
name="" ws="" run_cmd=""
|
||||||
@@ -85,9 +110,48 @@ case "$cmd" in
|
|||||||
*) run_cmd="$1"; shift ;;
|
*) run_cmd="$1"; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
herdr workspace create --label "${TMUX_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true
|
"$REAL_HERDR" workspace create --label "${HERDR_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
|
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)
|
kill-session)
|
||||||
sess=""
|
sess=""
|
||||||
@@ -97,8 +161,8 @@ case "$cmd" in
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
herdr session stop "$sess" >/dev/null 2>&1 || true
|
"$REAL_HERDR" session stop "$sess" >/dev/null 2>&1 || true
|
||||||
herdr session delete "$sess" >/dev/null 2>&1 || true
|
"$REAL_HERDR" session delete "$sess" >/dev/null 2>&1 || true
|
||||||
;;
|
;;
|
||||||
list-panes)
|
list-panes)
|
||||||
sess="" format=""
|
sess="" format=""
|
||||||
@@ -109,7 +173,7 @@ case "$cmd" in
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
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 [ -n "$info" ]; then
|
||||||
if [ "$format" = '#{pane_pid}|#{pane_current_path}|#{pane_current_command}' ]; then
|
if [ "$format" = '#{pane_pid}|#{pane_current_path}|#{pane_current_command}' ]; then
|
||||||
python3 -c "
|
python3 -c "
|
||||||
@@ -138,7 +202,7 @@ except Exception:
|
|||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
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)
|
send-keys)
|
||||||
sess="" key=""
|
sess="" key=""
|
||||||
@@ -151,7 +215,7 @@ except Exception:
|
|||||||
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
|
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
|
||||||
key="Enter"
|
key="Enter"
|
||||||
fi
|
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)
|
set-buffer)
|
||||||
text=""
|
text=""
|
||||||
@@ -172,51 +236,53 @@ except Exception:
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
if [ -f "$wrapper_dir/tmp_buffer" ]; then
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
delete-buffer)
|
delete-buffer)
|
||||||
rm -f "$wrapper_dir/tmp_buffer"
|
rm -f "$wrapper_dir/tmp_buffer"
|
||||||
;;
|
;;
|
||||||
ls)
|
ls)
|
||||||
herdr session list --json 2>/dev/null | python3 -c "
|
"$REAL_HERDR" agent list 2>/dev/null | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
try:
|
try:
|
||||||
for s in json.load(sys.stdin):
|
data = json.load(sys.stdin)
|
||||||
print(f\"{s['name']}|{s.get('created_at_epoch', 0)}\")
|
res = data.get('result', data)
|
||||||
|
for a in res.get('agents', []):
|
||||||
|
print(f\"{a['name']}|0\")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
" || true
|
" || true
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
true
|
"$REAL_HERDR" "$cmd" "$@"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$wrapper_dir/tmux"
|
chmod +x "$wrapper_dir/herdr"
|
||||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||||
export PATH="$wrapper_dir:$PATH"
|
export PATH="$wrapper_dir:$PATH"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
mam_tmux() {
|
mam_herdr() {
|
||||||
_init_tmux_isolation
|
_init_herdr_isolation
|
||||||
"$WORKSPACE_ROOT/.mam/shim/tmux" "$@"
|
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
_tmux() {
|
_herdr() {
|
||||||
mam_tmux "$@"
|
mam_herdr "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
tmux() {
|
herdr() {
|
||||||
mam_tmux "$@"
|
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.
|
# Query agent-sessions.yaml to find the herdr_server associated with a session.
|
||||||
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
|
# Fallback to HERDR_SERVER_NAME or 'default' if not registered or field is missing.
|
||||||
# Prints the resolved server name on stdout.
|
# Prints the resolved server name on stdout.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
load_state_json() {
|
load_state_json() {
|
||||||
@@ -236,7 +302,7 @@ try:
|
|||||||
cursor = conn.execute('SELECT data FROM sessions')
|
cursor = conn.execute('SELECT data FROM sessions')
|
||||||
for r in cursor.fetchall():
|
for r in cursor.fetchall():
|
||||||
db_sessions.append(json.loads(r[0]))
|
db_sessions.append(json.loads(r[0]))
|
||||||
d['tmux_sessions'] = db_sessions
|
d['herdr_sessions'] = db_sessions
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -259,24 +325,43 @@ print(json.dumps(d, ensure_ascii=False))
|
|||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_tmux_server() {
|
resolve_herdr_workspace() {
|
||||||
local session_name="$1"
|
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
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
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:
|
if s.get('name') == name:
|
||||||
print(s.get('tmux_server', 'default'))
|
print(s.get('herdr_workspace', 'default'))
|
||||||
sys.exit(0)
|
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>
|
# 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,
|
# slug = the two trailing path components of the absolute workspace,
|
||||||
# '_' -> '-', lowercased, joined with '-'
|
# '_' -> '-', lowercased, joined with '-'
|
||||||
# name = "<slug>-creator-<agent>"
|
# name = "<slug>-creator-<agent>"
|
||||||
@@ -417,33 +502,33 @@ db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|||||||
def _validate(d):
|
def _validate(d):
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
raise SystemExit("VALIDATE: top-level is not a mapping")
|
raise SystemExit("VALIDATE: top-level is not a mapping")
|
||||||
sessions = d.get('tmux_sessions', [])
|
sessions = d.get('herdr_sessions', [])
|
||||||
if not isinstance(sessions, list):
|
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'}
|
valid = {'running', 'terminated', 'archived', 'stopped'}
|
||||||
for i, s in enumerate(sessions):
|
for i, s in enumerate(sessions):
|
||||||
if not isinstance(s, dict):
|
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'):
|
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()):
|
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:
|
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):
|
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')
|
iso = s.get('isolation')
|
||||||
if iso is not None:
|
if iso is not None:
|
||||||
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
|
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):
|
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):
|
def get_all_sessions_status(d):
|
||||||
import hashlib
|
import hashlib
|
||||||
res = {}
|
res = {}
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
name = s.get('name')
|
name = s.get('name')
|
||||||
ser = json.dumps(s, sort_keys=True)
|
ser = json.dumps(s, sort_keys=True)
|
||||||
res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest()
|
res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest()
|
||||||
@@ -484,7 +569,7 @@ try:
|
|||||||
else:
|
else:
|
||||||
d = {}
|
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 = []
|
db_sessions = []
|
||||||
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
|
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
|
||||||
for s_row in cursor.fetchall():
|
for s_row in cursor.fetchall():
|
||||||
@@ -497,9 +582,9 @@ try:
|
|||||||
db_sessions.append(s_data)
|
db_sessions.append(s_data)
|
||||||
|
|
||||||
if db_sessions:
|
if db_sessions:
|
||||||
d['tmux_sessions'] = db_sessions
|
d['herdr_sessions'] = db_sessions
|
||||||
elif 'tmux_sessions' not in d:
|
elif 'herdr_sessions' not in d:
|
||||||
d['tmux_sessions'] = []
|
d['herdr_sessions'] = []
|
||||||
|
|
||||||
old_sessions = get_all_sessions_status(d)
|
old_sessions = get_all_sessions_status(d)
|
||||||
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
|
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())
|
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
|
||||||
|
|
||||||
# Role immutability check
|
# Role immutability check
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
name = s.get('name')
|
name = s.get('name')
|
||||||
if name in old_roles and s.get('role') != old_roles[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}")
|
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)
|
# ID Uniqueness Check (T2)
|
||||||
running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']
|
running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']
|
||||||
id_to_session = {}
|
id_to_session = {}
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('status') == 'running':
|
if s.get('status') == 'running':
|
||||||
s_name = s.get('name')
|
s_name = s.get('name')
|
||||||
for k in running_keys:
|
for k in running_keys:
|
||||||
@@ -529,11 +614,11 @@ try:
|
|||||||
_validate(d)
|
_validate(d)
|
||||||
|
|
||||||
# Separate globals and sessions for normalization
|
# 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),))
|
conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),))
|
||||||
|
|
||||||
current_names = []
|
current_names = []
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
name = s.get('name')
|
name = s.get('name')
|
||||||
status = s.get('status')
|
status = s.get('status')
|
||||||
pane_cwd = (s.get('pane') or {}).get('cwd', '')
|
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
|
# 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
|
# global agent_identities id unless that id's project_cwd matches THIS
|
||||||
# workspace. Resolution order:
|
# 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)
|
# (claude_session_id_own / agy_conversation_id_own)
|
||||||
# 2) on-disk scan scoped to this workspace
|
# 2) on-disk scan scoped to this workspace
|
||||||
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
|
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
|
||||||
@@ -668,7 +753,7 @@ except Exception:
|
|||||||
d = {}
|
d = {}
|
||||||
|
|
||||||
running_ids = set()
|
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 s_item.get('status') == 'running':
|
||||||
if target and s_item.get('name') == target:
|
if target and s_item.get('name') == target:
|
||||||
continue
|
continue
|
||||||
@@ -687,7 +772,7 @@ def emit(u):
|
|||||||
|
|
||||||
# Fetch all sessions matching this workspace
|
# Fetch all sessions matching this workspace
|
||||||
sessions = []
|
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:
|
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
||||||
sessions.append(s)
|
sessions.append(s)
|
||||||
|
|
||||||
@@ -1013,7 +1098,7 @@ try:
|
|||||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||||
if row:
|
if row:
|
||||||
d = json.loads(row[0])
|
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':
|
if s.get('name') == name and s.get('status') == 'stopped':
|
||||||
print(f"stopped_at={s.get('stopped_at', '?')}")
|
print(f"stopped_at={s.get('stopped_at', '?')}")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
@@ -1022,7 +1107,7 @@ try:
|
|||||||
elif os.path.exists(yaml_path):
|
elif os.path.exists(yaml_path):
|
||||||
with open(yaml_path) as f:
|
with open(yaml_path) as f:
|
||||||
d = yaml.safe_load(f) or {}
|
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':
|
if s.get('name') == name and s.get('status') == 'stopped':
|
||||||
print(f"stopped_at={s.get('stopped_at', '?')}")
|
print(f"stopped_at={s.get('stopped_at', '?')}")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
@@ -1139,9 +1224,9 @@ start_watchdog() {
|
|||||||
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
||||||
wait_for_tui_ready() {
|
wait_for_tui_ready() {
|
||||||
local sess="$1" agent="$2"
|
local sess="$1" agent="$2"
|
||||||
local local_tmux="tmux" i
|
local local_herdr="herdr" i
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
if [ -n "${HERDR_SERVER_NAME:-}" ] && [ "$HERDR_SERVER_NAME" != "default" ]; then
|
||||||
local_tmux="tmux -L $TMUX_SERVER_NAME"
|
local_herdr="herdr -L $HERDR_SERVER_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
@@ -1150,7 +1235,7 @@ wait_for_tui_ready() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
local content
|
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
|
if [ -n "$content" ]; then
|
||||||
case "$agent" in
|
case "$agent" in
|
||||||
claude)
|
claude)
|
||||||
@@ -1186,7 +1271,7 @@ wait_for_tui_ready() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# inject_instructions <session_name> <instructions> [job_id]
|
# 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).
|
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
|
||||||
inject_instructions() {
|
inject_instructions() {
|
||||||
send_keys_safe "$1" "$2" "${3:-onboard}"
|
send_keys_safe "$1" "$2" "${3:-onboard}"
|
||||||
@@ -1200,23 +1285,23 @@ inject_instructions() {
|
|||||||
# Callers MUST handle non-zero.
|
# Callers MUST handle non-zero.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Server-aware tmux (same isolation rule as inject_instructions).
|
# Server-aware herdr (same isolation rule as inject_instructions).
|
||||||
_sks_tmux() {
|
_sks_herdr() {
|
||||||
mam_tmux "$@"
|
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
|
# 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.
|
# 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}"; }
|
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
|
||||||
|
|
||||||
# _wait_session_gone <sess> [max_sec=5]
|
# _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() {
|
_wait_session_gone() {
|
||||||
local sess="$1" max="${2:-5}" i
|
local sess="$1" max="${2:-5}" i
|
||||||
for ((i = 0; i < max * 4; i++)); do
|
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
|
sleep 0.25
|
||||||
done
|
done
|
||||||
return 1
|
return 1
|
||||||
@@ -1263,7 +1348,7 @@ send_keys_safe() {
|
|||||||
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
||||||
while _pane_dialog_open "$sess"; do
|
while _pane_dialog_open "$sess"; do
|
||||||
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
||||||
_sks_tmux send-keys -t "$sess" Escape
|
_sks_herdr send-keys -t "$sess" Escape
|
||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
@@ -1273,11 +1358,11 @@ send_keys_safe() {
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
_sks_tmux set-buffer -b "sks_$job_id" "$text"
|
_sks_herdr set-buffer -b "sks_$job_id" "$text"
|
||||||
_sks_tmux paste-buffer -b "sks_$job_id" -t "$sess"
|
_sks_herdr paste-buffer -b "sks_$job_id" -t "$sess"
|
||||||
_sks_tmux delete-buffer -b "sks_$job_id" 2>/dev/null || true
|
_sks_herdr delete-buffer -b "sks_$job_id" 2>/dev/null || true
|
||||||
if [[ "$sess" =~ "agy" ]]; then
|
if [[ "$sess" =~ "agy" ]]; then
|
||||||
_sks_tmux send-keys -t "$sess" C-m
|
_sks_herdr send-keys -t "$sess" C-m
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
@@ -1292,7 +1377,7 @@ send_keys_safe() {
|
|||||||
|
|
||||||
for try in 1 2 3; do
|
for try in 1 2 3; do
|
||||||
pre_submit=$(_pane_capture "$sess")
|
pre_submit=$(_pane_capture "$sess")
|
||||||
_sks_tmux send-keys -t "$sess" C-m
|
_sks_herdr send-keys -t "$sess" C-m
|
||||||
sleep "$try"
|
sleep "$try"
|
||||||
local cur_content
|
local cur_content
|
||||||
cur_content=$(_pane_capture "$sess")
|
cur_content=$(_pane_capture "$sess")
|
||||||
@@ -1321,13 +1406,13 @@ handle_startup_dialogs() {
|
|||||||
while [ "$waited" -lt "$timeout" ]; do
|
while [ "$waited" -lt "$timeout" ]; do
|
||||||
pane=$(_pane_tail "$sess" 20)
|
pane=$(_pane_tail "$sess" 20)
|
||||||
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
|
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
|
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
|
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
|
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
|
elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -1338,4 +1423,4 @@ handle_startup_dialogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Auto-initialize the herdr translation shim wrapper on library source
|
# Auto-initialize the herdr translation shim wrapper on library source
|
||||||
_init_tmux_isolation
|
_init_herdr_isolation
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-create
|
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
|
version: 1.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
environments: [terminal, tmux]
|
environments: [terminal, herdr]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
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]
|
related_skills: [multi-agent-mux-resume, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
|
||||||
prereq_skills: [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).
|
> **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).
|
> **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
|
## 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>`.
|
> 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:
|
Before doing anything, verify the environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1) tmux available and isolated server status
|
# 1) herdr available and isolated server status
|
||||||
command -v tmux || { echo "ERROR: tmux not installed"; exit 1; }
|
command -v herdr || { echo "ERROR: herdr not installed"; exit 1; }
|
||||||
echo "Tmux server name: ${TMUX_SERVER_NAME:-default}"
|
echo "Herdr server name: ${HERDR_SERVER_NAME:-default}"
|
||||||
|
|
||||||
# 2) claude / agy available
|
# 2) claude / agy available
|
||||||
command -v claude # required for --agent claude
|
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
|
## 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, `_`→`-`)
|
- `<workspace-slug>` = `basename $(dirname $WORKSPACE)` `-` `basename $WORKSPACE` (lowercase, `_`→`-`)
|
||||||
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
|
- 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
|
- never re-derive this by hand — source lib.sh and call the function
|
||||||
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
|
- **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
|
- 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
|
### How to use
|
||||||
1. **Via Environment Variable**:
|
1. **Via Environment Variable**:
|
||||||
```bash
|
```bash
|
||||||
export TMUX_SERVER_NAME=multi-agent-canary
|
export HERDR_SERVER_NAME=multi-agent-canary
|
||||||
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' tmux server.
|
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' herdr server.
|
||||||
```
|
```
|
||||||
2. **Via Option Flag**:
|
2. **Via Option Flag**:
|
||||||
```bash
|
```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**:
|
3. **Submit Job Integration**:
|
||||||
You can automatically register a delegated job with a prompt when creating a session:
|
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
|
### Recommended Alias
|
||||||
You can set an alias in your shell to easily query sessions on the isolated server:
|
You can set an alias in your shell to easily query sessions on the isolated server:
|
||||||
```bash
|
```bash
|
||||||
alias tmc='tmux -L multi-agent-canary'
|
alias tmc='herdr -L multi-agent-canary'
|
||||||
tmc ls # Lists only your multi-agent sessions
|
tmc ls # Lists only your multi-agent sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Safety Rules (Pitfall 29 Summary)
|
### 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).
|
- 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 `TMUX_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
|
- By using an isolated server via `HERDR_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -107,25 +107,25 @@ source .agents/skills/lib.sh
|
|||||||
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
|
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
|
||||||
|
|
||||||
# 1. If session already alive, fail fast
|
# 1. If session already alive, fail fast
|
||||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && {
|
herdr 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."
|
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach or multi-agent-mux-stop first."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Spawn the tmux session with the agent inside
|
# 2. Spawn the herdr session with the agent inside
|
||||||
case "$AGENT" in
|
case "$AGENT" in
|
||||||
claude)
|
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)
|
# Use the wrapper if it exists (LOCAL_BIN env var overrides default $HOME/.local/bin)
|
||||||
local_bin="${LOCAL_BIN:-$HOME/.local/bin}"
|
local_bin="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||||
if [ -x "$local_bin/$SESSION_NAME" ]; then
|
if [ -x "$local_bin/$SESSION_NAME" ]; then
|
||||||
nohup "$local_bin/$SESSION_NAME" >/dev/null 2>&1 &
|
nohup "$local_bin/$SESSION_NAME" >/dev/null 2>&1 &
|
||||||
else
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
agy)
|
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 ;;
|
*) echo "ERROR: --agent must be claude or agy, got: $AGENT"; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -134,22 +134,22 @@ esac
|
|||||||
sleep 6
|
sleep 6
|
||||||
|
|
||||||
# 4. Capture pane metadata
|
# 4. Capture pane metadata
|
||||||
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
|
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
|
||||||
PANE_CWD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
|
PANE_CWD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
|
||||||
PANE_CMD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
|
PANE_CMD=$(herdr 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)
|
HERDR_EPOCH=$(herdr list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Registering the session in agent-sessions.yaml
|
## 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
|
```yaml
|
||||||
- name: <SESSION_NAME>
|
- name: <SESSION_NAME>
|
||||||
status: running
|
status: running
|
||||||
tmux_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
|
herdr_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
|
||||||
tmux_session_epoch: <TMUX_EPOCH>
|
herdr_session_epoch: <HERDR_EPOCH>
|
||||||
tmux_server: <TMUX_SERVER_NAME> # Isolated server name (default: 'default')
|
herdr_server: <HERDR_SERVER_NAME> # Isolated server name (default: 'default')
|
||||||
pane:
|
pane:
|
||||||
index: 0
|
index: 0
|
||||||
pid: <PANE_PID>
|
pid: <PANE_PID>
|
||||||
@@ -162,9 +162,9 @@ After spawn, append a new `tmux_sessions[]` entry to `.mam/agent-sessions.yaml`:
|
|||||||
plan: <from TUI status>
|
plan: <from TUI status>
|
||||||
account: <from TUI status>
|
account: <from TUI status>
|
||||||
version: <from TUI status>
|
version: <from TUI status>
|
||||||
start_command: <the exact tmux new-session command used>
|
start_command: <the exact herdr new-session command used>
|
||||||
attach_command: "tmux attach -t <SESSION_NAME>"
|
attach_command: "herdr attach -t <SESSION_NAME>"
|
||||||
kill_command: "tmux kill-session -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):
|
`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
|
## 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.
|
- **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).
|
- **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 tmux `cwd` — relative paths break when tmux respawns in a different shell context.
|
- **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.
|
- **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
|
## Verification
|
||||||
@@ -196,28 +196,28 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
|
|||||||
After spawn + YAML append:
|
After spawn + YAML append:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. tmux session is alive
|
# 1. herdr session is alive
|
||||||
tmux has-session -t "$SESSION_NAME" && echo OK || echo MISSING
|
herdr has-session -t "$SESSION_NAME" && echo OK || echo MISSING
|
||||||
|
|
||||||
# 2. pane has the expected cmd + cwd
|
# 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
|
# 3. agent-sessions.yaml has the new entry
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import yaml
|
import yaml
|
||||||
d = yaml.safe_load(open('.mam/agent-sessions.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'
|
assert '$SESSION_NAME' in names, 'session not registered'
|
||||||
print('OK:', names)
|
print('OK:', names)
|
||||||
"
|
"
|
||||||
|
|
||||||
# 4. Optional: check the TUI status via capture-pane
|
# 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
|
## When NOT to use this skill
|
||||||
|
|
||||||
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
- **Resuming an old conversation** → `multi-agent-mux-resume`
|
||||||
- **Killing an existing session** → `multi-agent-mux-stop`
|
- **Killing an existing session** → `multi-agent-mux-stop`
|
||||||
- **Just attaching to an existing session** → `tmux attach -t <name>` (no skill needed)
|
- **Just attaching to an existing session** → `herdr attach -t <name>` (no skill needed)
|
||||||
- **One-shot print mode (claude -p "...")** → no tmux needed; use `claude-code` skill's print mode
|
- **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]
|
# bash create_session.sh --workspace <path> --agent <claude|agy> --role <role> [--session <name>] [--wrapper]
|
||||||
#
|
#
|
||||||
# 동작:
|
# 동작:
|
||||||
# 1) preflight: tmux/claude/agy 가용성, workspace 존재
|
# 1) preflight: herdr/claude/agy 가용성, workspace 존재
|
||||||
# 2) tmux 세션 이름 결정 (--session 없으면 자동)
|
# 2) herdr 세션 이름 결정 (--session 없으면 자동)
|
||||||
# 3) tmux 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
|
# 3) herdr 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
|
||||||
# 4) pane 메타 캡처 (pid, cmd, cwd)
|
# 4) pane 메타 캡처 (pid, cmd, cwd)
|
||||||
# 5) agent-sessions.yaml 에 tmux_sessions[] 엔트리 append
|
# 5) agent-sessions.yaml 에 herdr_sessions[] 엔트리 append
|
||||||
# 6) 검증 출력
|
# 6) 검증 출력
|
||||||
#
|
#
|
||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 = success
|
# 0 = success
|
||||||
# 1 = preflight failure
|
# 1 = preflight failure
|
||||||
# 2 = invalid args
|
# 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
|
# 4 = agent-sessions.yaml append failure
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -29,10 +29,10 @@ Options:
|
|||||||
--workspace PATH project directory (required)
|
--workspace PATH project directory (required)
|
||||||
--agent AGENT claude | agy | hermes | cline (required)
|
--agent AGENT claude | agy | hermes | cline (required)
|
||||||
--role ROLE assigned role (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
|
--wrapper force use of ~/.local/bin/<session> wrapper even if not present
|
||||||
--dry-run print commands without executing
|
--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
|
--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
|
--onboard automatically submit a project alignment/orientation job to the new agent
|
||||||
--no-isolate disable state isolation (shares global configuration/history)
|
--no-isolate disable state isolation (shares global configuration/history)
|
||||||
@@ -47,7 +47,7 @@ ROLE=""
|
|||||||
SESSION_NAME=""
|
SESSION_NAME=""
|
||||||
USE_WRAPPER=0
|
USE_WRAPPER=0
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
TMUX_SERVER_OPT=""
|
HERDR_SERVER_OPT=""
|
||||||
SUBMIT_JOB_PROMPT=""
|
SUBMIT_JOB_PROMPT=""
|
||||||
ONBOARD=0
|
ONBOARD=0
|
||||||
ISOLATE=1
|
ISOLATE=1
|
||||||
@@ -60,7 +60,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||||
--wrapper) USE_WRAPPER=1; shift ;;
|
--wrapper) USE_WRAPPER=1; shift ;;
|
||||||
--dry-run) DRY_RUN=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 ;;
|
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||||
--onboard) ONBOARD=1; shift ;;
|
--onboard) ONBOARD=1; shift ;;
|
||||||
--isolate) ISOLATE=1; shift ;; # legacy compatibility
|
--isolate) ISOLATE=1; shift ;; # legacy compatibility
|
||||||
@@ -70,8 +70,8 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -n "$TMUX_SERVER_OPT" ]; then
|
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||||
export TMUX_SERVER_NAME="$TMUX_SERVER_OPT"
|
export HERDR_SERVER_NAME="$HERDR_SERVER_OPT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Preflight
|
# Preflight
|
||||||
@@ -114,8 +114,8 @@ if [ -z "$SESSION_NAME" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 이미 살아있으면 실패
|
# 이미 살아있으면 실패
|
||||||
if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
if _herdr 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
|
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ if [ "$ISOLATE" = "1" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# tmux 세션 띄우기
|
# herdr 세션 띄우기
|
||||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||||
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ if [ -n "$ISOLATION_ROOT" ]; then
|
|||||||
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
|
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
|
||||||
fi
|
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"
|
RESOLVED_BIN="$AGENT"
|
||||||
if [ "$AGENT" = "cline" ]; then
|
if [ "$AGENT" = "cline" ]; then
|
||||||
if command -v cline >/dev/null 2>&1; then
|
if command -v cline >/dev/null 2>&1; then
|
||||||
@@ -184,29 +184,29 @@ spawn() {
|
|||||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||||
disown
|
disown
|
||||||
else
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
agy|hermes|cline)
|
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 ;;
|
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
spawn
|
spawn
|
||||||
|
|
||||||
# Trap for rolling back/cleaning up tmux session if script exits due to error
|
# Trap for rolling back/cleaning up herdr session if script exits due to error
|
||||||
cleanup_tmux_on_error() {
|
cleanup_herdr_on_error() {
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
if [ $exit_code -ne 0 ]; then
|
if [ $exit_code -ne 0 ]; then
|
||||||
echo "⚠️ Error occurred during initialization. Rolling back and killing tmux session '$SESSION_NAME'..." >&2
|
echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2
|
||||||
_tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
||||||
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
|
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
|
||||||
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
|
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
|
||||||
case "$ISOLATION_ROOT" in
|
case "$ISOLATION_ROOT" in
|
||||||
@@ -215,7 +215,7 @@ cleanup_tmux_on_error() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup_tmux_on_error EXIT
|
trap cleanup_herdr_on_error EXIT
|
||||||
|
|
||||||
# TUI 준비 대기
|
# TUI 준비 대기
|
||||||
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
|
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
|
||||||
@@ -224,14 +224,14 @@ if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# pane 메타 캡처
|
# pane 메타 캡처
|
||||||
PANE_PID=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
|
PANE_PID=$(_herdr 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_CWD=$(_herdr 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")
|
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
|
||||||
TMUX_EPOCH=$(date +%s)
|
HERDR_EPOCH=$(date +%s)
|
||||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4)
|
# 시작 명령 (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 is specified, automatically build the onboarding prompt
|
||||||
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
|
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
|
||||||
@@ -255,14 +255,14 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
|||||||
else
|
else
|
||||||
delegate_agent="antigravity-cli"
|
delegate_agent="antigravity-cli"
|
||||||
fi
|
fi
|
||||||
agent_session="tmux:$SESSION_NAME"
|
agent_session="herdr:$SESSION_NAME"
|
||||||
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
|
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
|
||||||
echo "Submitted delegated job: $DELEGATE_JOB_ID"
|
echo "Submitted delegated job: $DELEGATE_JOB_ID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
|
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
|
||||||
mkdir -p "$(dirname "$AGENT_SESSIONS_YAML")"
|
mkdir -p "$(dirname "$AGENT_SESSIONS_YAML")"
|
||||||
echo "tmux_sessions: []" > "$AGENT_SESSIONS_YAML"
|
echo "herdr_sessions: []" > "$AGENT_SESSIONS_YAML"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
|
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
|
||||||
@@ -276,9 +276,9 @@ fi
|
|||||||
|
|
||||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
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" \
|
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" \
|
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" \
|
||||||
ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \
|
ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \
|
||||||
ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF'
|
ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF'
|
||||||
@@ -286,11 +286,11 @@ name = os.environ['SESSION_NAME']
|
|||||||
agent = os.environ['AGENT']
|
agent = os.environ['AGENT']
|
||||||
role = os.environ['ROLE']
|
role = os.environ['ROLE']
|
||||||
pid = os.environ.get('PANE_PID', '')
|
pid = os.environ.get('PANE_PID', '')
|
||||||
epoch = os.environ.get('TMUX_EPOCH', '')
|
epoch = os.environ.get('HERDR_EPOCH', '')
|
||||||
server_name = os.environ.get('TMUX_SERVER_NAME', 'default')
|
server_name = os.environ.get('HERDR_SERVER_NAME', 'default')
|
||||||
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
|
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 는
|
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
|
||||||
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
|
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
|
||||||
@@ -304,9 +304,9 @@ entry = {
|
|||||||
'name': name,
|
'name': name,
|
||||||
'status': 'running',
|
'status': 'running',
|
||||||
'role': role,
|
'role': role,
|
||||||
'tmux_session_created_at': os.environ['NOW_ISO'],
|
'herdr_session_created_at': os.environ['NOW_ISO'],
|
||||||
'tmux_session_epoch': int(epoch) if epoch.isdigit() else 0,
|
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
|
||||||
'tmux_server': server_name,
|
'herdr_server': server_name,
|
||||||
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
|
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
|
||||||
'pane': {
|
'pane': {
|
||||||
'index': 0,
|
'index': 0,
|
||||||
@@ -373,7 +373,7 @@ PYEOF
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== created ==="
|
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
|
if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||||
echo "delegate job: $DELEGATE_JOB_ID"
|
echo "delegate job: $DELEGATE_JOB_ID"
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ On failure run: $pub --event error --detail '<one-line reason>'.
|
|||||||
|
|
||||||
Task: $SUBMIT_JOB_PROMPT"
|
Task: $SUBMIT_JOB_PROMPT"
|
||||||
|
|
||||||
# Inject instructions into the tmux pane
|
# Inject instructions into the herdr pane
|
||||||
rc=0
|
rc=0
|
||||||
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
|
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
|
||||||
if [ "$rc" -ne 0 ]; then
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ multi-agent-mux-delegate-job submit \
|
|||||||
### 신규 옵션 상세:
|
### 신규 옵션 상세:
|
||||||
* `--type`: 작업 위임 타입을 지정합니다. (`direct`, `loop`, `discuss`)
|
* `--type`: 작업 위임 타입을 지정합니다. (`direct`, `loop`, `discuss`)
|
||||||
* `--reviewer`: 리뷰를 담당할 에이전트 이름입니다 (기본값: `hermes`).
|
* `--reviewer`: 리뷰를 담당할 에이전트 이름입니다 (기본값: `hermes`).
|
||||||
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 tmux 세션 이름입니다 (기본값: `tmux:hermes`).
|
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 herdr 세션 이름입니다 (기본값: `herdr:hermes`).
|
||||||
* `--max-iterations`: 루프 또는 토론의 최대 반복 횟수입니다 (기본값: `5`).
|
* `--max-iterations`: 루프 또는 토론의 최대 반복 횟수입니다 (기본값: `5`).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -73,10 +73,10 @@ stateDiagram-v2
|
|||||||
### 단계별 상세 동작 프로토콜:
|
### 단계별 상세 동작 프로토콜:
|
||||||
|
|
||||||
1. **작업자(Worker) 실행**:
|
1. **작업자(Worker) 실행**:
|
||||||
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `tmux:claude`)으로 설정하여 전달합니다.
|
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `herdr:claude`)으로 설정하여 전달합니다.
|
||||||
* 작업자가 수행을 완료하고 `completed` 이벤트를 발행하면 오케스트레이터가 이를 가로챕니다.
|
* 작업자가 수행을 완료하고 `completed` 이벤트를 발행하면 오케스트레이터가 이를 가로챕니다.
|
||||||
2. **리뷰어(Reviewer)로 스위칭**:
|
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."*
|
> *"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`으로 리셋하여 리뷰어 세션이 잡을 집어갈 수 있도록 합니다.
|
* 상태를 다시 `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:
|
The `multi-agent-mux-delegate-job` bash wrapper handles job registration, subscriber management, agent session targeting, and validation hooks:
|
||||||
|
|
||||||
```bash
|
```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 \
|
multi-agent-mux-delegate-job submit \
|
||||||
--agent <claude-code|hermes-agent|agy-agent|cline-agent|human> \
|
--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" \
|
--prompt "Task description or instructions here" \
|
||||||
--role <Worker|Planner|Reviewer> \
|
--role <Worker|Planner|Reviewer> \
|
||||||
--timeout 3600 --idle-timeout 120
|
--timeout 3600 --idle-timeout 120
|
||||||
|
|
||||||
# 2) Submit a job with a feedback loop (Worker-Reviewer Loop)
|
# 2) Submit a job with a feedback loop (Worker-Reviewer Loop)
|
||||||
multi-agent-mux-delegate-job submit \
|
multi-agent-mux-delegate-job submit \
|
||||||
--agent <worker_agent> --agent-session tmux:<worker_session> \
|
--agent <worker_agent> --agent-session herdr:<worker_session> \
|
||||||
--type loop --reviewer <reviewer_agent> --reviewer-session tmux:<reviewer_session> \
|
--type loop --reviewer <reviewer_agent> --reviewer-session herdr:<reviewer_session> \
|
||||||
--prompt "Task description"
|
--prompt "Task description"
|
||||||
|
|
||||||
# 3) Check job status and audit logs
|
# 3) Check job status and audit logs
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The registry is the **single source of truth** for delegated work. Job metadata
|
The registry is the **single source of truth** for delegated work. Job metadata
|
||||||
(id, prompt, broker, status, timeouts) lives in files, **not** environment
|
(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
|
parallel without collisions, and `publish_event.py` / `job_subscriber.py` can
|
||||||
reconstruct everything they need from the registry alone.
|
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",
|
"updated_at": "2026-06-19T09:32:00Z",
|
||||||
"prompt": "정렬 문제 10개를 만들어 sort_problems.md로 저장…",
|
"prompt": "정렬 문제 10개를 만들어 sort_problems.md로 저장…",
|
||||||
"agent": "claude-code",
|
"agent": "claude-code",
|
||||||
"agent_session": "tmux:claude",
|
"agent_session": "herdr:claude",
|
||||||
"broker": {
|
"broker": {
|
||||||
"host": "broker.hivemq.com",
|
"host": "broker.hivemq.com",
|
||||||
"port": 1883,
|
"port": 1883,
|
||||||
@@ -69,7 +69,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
|
|||||||
|
|
||||||
Every read-modify-write (`register_job`, `pick_pending`, `update_status`,
|
Every read-modify-write (`register_job`, `pick_pending`, `update_status`,
|
||||||
`next_seq`) runs inside `registry_lock(registry_dir)`, an exclusive
|
`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.
|
one machine.
|
||||||
|
|
||||||
### Production — SQLite WAL
|
### Production — SQLite WAL
|
||||||
@@ -83,8 +83,8 @@ signatures stay identical; only the storage backend changes.
|
|||||||
|
|
||||||
## 4. How multiple sessions take only their own work
|
## 4. How multiple sessions take only their own work
|
||||||
|
|
||||||
Each tmux session carries an `agent_session` label (`tmux:claude`,
|
Each herdr session carries an `agent_session` label (`herdr:claude`,
|
||||||
`tmux:claude-a`, `tmux:claude-b`, …). `pick_pending(agent_session)`:
|
`herdr:claude-a`, `herdr:claude-b`, …). `pick_pending(agent_session)`:
|
||||||
|
|
||||||
1. acquires the registry lock,
|
1. acquires the registry lock,
|
||||||
2. scans for the **oldest** record with `status == "pending"` **and**
|
2. scans for the **oldest** record with `status == "pending"` **and**
|
||||||
@@ -99,7 +99,7 @@ the job already `running` and moves on.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# session A only ever runs its own pending jobs
|
# 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
|
```bash
|
||||||
PY=.venv/bin/python
|
PY=.venv/bin/python
|
||||||
$PY scripts/registry.py register --prompt "…" --agent claude-code \
|
$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 # human table
|
||||||
$PY scripts/registry.py list --json # full records
|
$PY scripts/registry.py list --json # full records
|
||||||
$PY scripts/registry.py get --job <id> # one record
|
$PY scripts/registry.py get --job <id> # one record
|
||||||
$PY scripts/registry.py status --job <id> --set completed # set status
|
$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
|
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):
|
def registry_lock(registry_dir: str):
|
||||||
"""Advisory exclusive lock over the whole registry dir via fcntl.
|
"""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
|
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
|
sessions never claim the same pending job. For multi-host delegation move
|
||||||
to SQLite WAL (see references/registry.md)."""
|
to SQLite WAL (see references/registry.md)."""
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Exit codes:
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
|
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
|
publish_event.py --job <id> --event completed --retained
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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("--job", help="job id to publish for")
|
||||||
target.add_argument("--pick-pending", action="store_true",
|
target.add_argument("--pick-pending", action="store_true",
|
||||||
help="auto-select a pending job for --agent-session")
|
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")
|
help="session label used with --pick-pending")
|
||||||
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
|
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
|
||||||
parser.add_argument("--detail", default="")
|
parser.add_argument("--detail", default="")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def generate_job_id(bits: int = 32) -> str:
|
|||||||
def register_job(
|
def register_job(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
agent: str = "claude-code",
|
agent: str = "claude-code",
|
||||||
agent_session: str = "tmux:claude",
|
agent_session: str = "herdr:claude",
|
||||||
role: str = "Worker",
|
role: str = "Worker",
|
||||||
broker: Optional[Dict[str, Any]] = None,
|
broker: Optional[Dict[str, Any]] = None,
|
||||||
timeout_sec: int = 3600,
|
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]:
|
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
|
"""Claim the oldest ``pending`` job for ``agent_session``, flipping it to
|
||||||
``running`` atomically under the lock. Returns the job id, or None if no
|
``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."""
|
without two sessions grabbing the same job."""
|
||||||
with registry_lock(registry_dir):
|
with registry_lock(registry_dir):
|
||||||
candidates = []
|
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 = sub.add_parser("register", help="create a pending job; prints the job id")
|
||||||
p_reg.add_argument("--prompt", required=True)
|
p_reg.add_argument("--prompt", required=True)
|
||||||
p_reg.add_argument("--agent", default="claude-code")
|
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("--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("--timeout", type=int, default=3600)
|
||||||
p_reg.add_argument("--idle-timeout", type=int, default=120)
|
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_feedback.add_argument("--job", required=True)
|
||||||
|
|
||||||
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
|
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(
|
p_logs = sub.add_parser(
|
||||||
"logs",
|
"logs",
|
||||||
|
|||||||
@@ -186,6 +186,6 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
|||||||
|
|
||||||
- **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
|
- **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
|
||||||
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
|
- **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 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오.
|
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오.
|
||||||
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
|
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ resolve_all_reviewers() {
|
|||||||
import os, json
|
import os, json
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
target_agent = os.environ.get('TARGET_AGENT')
|
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]
|
if 'reviewer' in s.get('role', '') and s.get('name') != target_agent]
|
||||||
print(','.join(reviewers))
|
print(','.join(reviewers))
|
||||||
"
|
"
|
||||||
@@ -172,7 +172,7 @@ import os, json
|
|||||||
name = os.environ.get('NAME')
|
name = os.environ.get('NAME')
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
agent = None
|
agent = None
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
agent = s.get('agent') or s.get('pane', {}).get('cmd')
|
agent = s.get('agent') or s.get('pane', {}).get('cmd')
|
||||||
break
|
break
|
||||||
@@ -198,7 +198,7 @@ resolve_planner_session() {
|
|||||||
import os, json
|
import os, json
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
planner = ''
|
planner = ''
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if 'planner' in s.get('role', ''):
|
if 'planner' in s.get('role', ''):
|
||||||
planner = s.get('name')
|
planner = s.get('name')
|
||||||
break
|
break
|
||||||
@@ -234,7 +234,7 @@ if [ "$PLAN_MODE" = true ]; then
|
|||||||
# Step 1.1: Request initial plan from Planner
|
# Step 1.1: Request initial plan from Planner
|
||||||
log_info "Requesting initial implementation plan from Planner..."
|
log_info "Requesting initial implementation plan from Planner..."
|
||||||
PLAN_JOB_OUTPUT=$(delegate_job_safe submit \
|
PLAN_JOB_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$PLANNER_SESSION" \
|
--agent-session "herdr:$PLANNER_SESSION" \
|
||||||
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Planner" \
|
--role "Planner" \
|
||||||
@@ -268,7 +268,7 @@ if [ "$PLAN_MODE" = true ]; then
|
|||||||
|
|
||||||
# Creator critique job
|
# Creator critique job
|
||||||
DEBATE_JOB_OUTPUT=$(delegate_job_safe submit \
|
DEBATE_JOB_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$TARGET_AGENT" \
|
--agent-session "herdr:$TARGET_AGENT" \
|
||||||
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Worker" \
|
--role "Worker" \
|
||||||
@@ -296,7 +296,7 @@ if [ "$PLAN_MODE" = true ]; then
|
|||||||
|
|
||||||
log_info "Planner refining plan with Creator's feedback..."
|
log_info "Planner refining plan with Creator's feedback..."
|
||||||
REFINE_JOB_OUTPUT=$(delegate_job_safe submit \
|
REFINE_JOB_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$PLANNER_SESSION" \
|
--agent-session "herdr:$PLANNER_SESSION" \
|
||||||
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Planner" \
|
--role "Planner" \
|
||||||
@@ -352,7 +352,7 @@ if [ -n "$CURRENT_PLAN" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
EXEC_JOB_OUTPUT=$(delegate_job_safe submit \
|
EXEC_JOB_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$TARGET_AGENT" \
|
--agent-session "herdr:$TARGET_AGENT" \
|
||||||
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Worker" \
|
--role "Worker" \
|
||||||
@@ -396,7 +396,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
|
|||||||
if [ "${#REVIEWERS[@]}" -eq 0 ]; then
|
if [ "${#REVIEWERS[@]}" -eq 0 ]; then
|
||||||
log_warn "No reviewers specified. Conducting Creator Self-Review..."
|
log_warn "No reviewers specified. Conducting Creator Self-Review..."
|
||||||
SELF_REV_OUTPUT=$(delegate_job_safe submit \
|
SELF_REV_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$TARGET_AGENT" \
|
--agent-session "herdr:$TARGET_AGENT" \
|
||||||
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Reviewer" \
|
--role "Reviewer" \
|
||||||
@@ -440,7 +440,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
REV_OUTPUT=$(delegate_job_safe submit \
|
REV_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$rev" \
|
--agent-session "herdr:$rev" \
|
||||||
--agent "$(resolve_agent_type "$rev")" \
|
--agent "$(resolve_agent_type "$rev")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Reviewer" \
|
--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..."
|
log_warn "Feedback involves complex code modifications. Diverting to Planner to revise plan..."
|
||||||
|
|
||||||
REFINE_PLAN_OUTPUT=$(delegate_job_safe submit \
|
REFINE_PLAN_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$PLANNER_SESSION" \
|
--agent-session "herdr:$PLANNER_SESSION" \
|
||||||
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Planner" \
|
--role "Planner" \
|
||||||
@@ -540,7 +540,7 @@ while [ "$loop_count" -le "$MAX_LOOP" ]; do
|
|||||||
|
|
||||||
# Creator execution corrective job
|
# Creator execution corrective job
|
||||||
CORRECT_JOB_OUTPUT=$(delegate_job_safe submit \
|
CORRECT_JOB_OUTPUT=$(delegate_job_safe submit \
|
||||||
--agent-session "tmux:$TARGET_AGENT" \
|
--agent-session "herdr:$TARGET_AGENT" \
|
||||||
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
|
||||||
--type "direct" \
|
--type "direct" \
|
||||||
--role "Worker" \
|
--role "Worker" \
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-monitor
|
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
|
version: 1.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
environments: [kanban, terminal, tmux]
|
environments: [kanban, terminal, herdr]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
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]
|
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-stop, kanban-orchestrator]
|
||||||
prereq_skills: [kanban-worker, multi-agent-mux-create]
|
prereq_skills: [kanban-worker, multi-agent-mux-create]
|
||||||
---
|
---
|
||||||
@@ -23,16 +23,16 @@ metadata:
|
|||||||
Dispatch a **Kanban worker** (in `goal_mode`) that:
|
Dispatch a **Kanban worker** (in `goal_mode`) that:
|
||||||
|
|
||||||
1. Every ~30s polls the actual state of:
|
1. Every ~30s polls the actual state of:
|
||||||
- `tmux ls` (which sessions are alive)
|
- `herdr ls` (which sessions are alive)
|
||||||
- `tmux list-panes -t <session> ...` (pane cmd, cwd, pid)
|
- `herdr list-panes -t <session> ...` (pane cmd, cwd, pid)
|
||||||
- `~/.claude/projects/<workspace-key>/*.jsonl` mtime + first-line sessionId
|
- `~/.claude/projects/<workspace-key>/*.jsonl` mtime + first-line sessionId
|
||||||
- `~/.gemini/antigravity-cli/cache/last_conversations.json` (agy workspace → conversation mapping)
|
- `~/.gemini/antigravity-cli/cache/last_conversations.json` (agy workspace → conversation mapping)
|
||||||
- `~/.gemini/antigravity-cli/conversations/<uuid>.db` mtime (agy)
|
- `~/.gemini/antigravity-cli/conversations/<uuid>.db` mtime (agy)
|
||||||
2. Compares the live state to `agent-sessions.yaml`
|
2. Compares the live state to `agent-sessions.yaml`
|
||||||
3. Detects 4 classes of drift:
|
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 terminated/archived/stopped**: herdr 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
|
- **yaml-only running, herdr dead**: YAML says `running`, herdr 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
|
- **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
|
- **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
|
4. Writes a Kanban `kanban_comment` on every drift event with diff details
|
||||||
5. Heartbeat every 5 minutes
|
5. Heartbeat every 5 minutes
|
||||||
@@ -40,14 +40,14 @@ Dispatch a **Kanban worker** (in `goal_mode`) that:
|
|||||||
|
|
||||||
## When to use
|
## 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 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 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"
|
- You're running multi-day work and want to know "what's actually running right now"
|
||||||
|
|
||||||
## When NOT to use
|
## 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
|
- A single, short session — overhead > benefit
|
||||||
- You don't have a Kanban dispatcher running
|
- 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:
|
You are the agent-sessions monitor. Every 30 seconds, do:
|
||||||
|
|
||||||
1. Read .mam/agent-sessions.yaml
|
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}'`
|
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 tmux state
|
3. For each session in the YAML, check the corresponding herdr state
|
||||||
4. For each tmux session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
|
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
|
5. For any drift, call `kanban_comment` with the diff
|
||||||
6. Sleep 30 seconds, then repeat
|
6. Sleep 30 seconds, then repeat
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ EOF
|
|||||||
|
|
||||||
The worker calls this script every 30s. It:
|
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)
|
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`
|
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`.
|
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`.
|
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
|
YAML: status=running, pane.pid=201132, cmd=claude
|
||||||
tmux: no session
|
herdr: no session
|
||||||
→ set status=terminated, terminated_at=<now>, termination_mode=auto-detected
|
→ 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`.
|
**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
|
(set by `multi-agent-mux-stop`) — are
|
||||||
left untouched. This is critical: a `stopped` row keeps its `resumable: true` and
|
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
|
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
|
cmd=agy, cwd=$WORKSPACE_ROOT/paper-pdf2md
|
||||||
YAML: no such session
|
YAML: no such session
|
||||||
→ register as new entry: status=running, last_visible_status=running, last_visible_note=auto-registered
|
→ 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)
|
### 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.
|
- **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.
|
- **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`).
|
- **`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.
|
- **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.
|
- **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
|
## Drift responses
|
||||||
|
|
||||||
- A. tmux dead + YAML running: auto-terminate YAML, comment
|
- A. herdr dead + YAML running: auto-terminate YAML, comment
|
||||||
- B. tmux alive not in YAML: auto-register, comment
|
- B. herdr alive not in YAML: auto-register, comment
|
||||||
- C. New session id from *.jsonl: update YAML, comment
|
- C. New session id from *.jsonl: update YAML, comment
|
||||||
- D. Stale UUID: comment only, no YAML change
|
- D. Stale UUID: comment only, no YAML change
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- Do NOT modify conversation artifacts (jsonl, db, brain/)
|
- 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 multi-agent-mux-create or multi-agent-mux-stop — only the user initiates those
|
||||||
- Do NOT call `git commit` / `git push`
|
- 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.
|
event from a third party can terminate your agent session.
|
||||||
3. **Mitigation**: Use `--subscribe` only on private TLS-enabled brokers
|
3. **Mitigation**: Use `--subscribe` only on private TLS-enabled brokers
|
||||||
(production mode). For PoC, prefer polling-based monitor (`--once` or
|
(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
|
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
|
`mqtt_common.py` (see FW-05). Ensure `auth_token` is set for each job
|
||||||
to enable signature validation — unauthenticated events will be dropped.
|
to enable signature validation — unauthenticated events will be dropped.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
|
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
|
||||||
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
|
# YAML ↔ herdr ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
|
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
|
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
|
||||||
# multi-agent-mux-status 스킬이 이걸 재사용.
|
# 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
|
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -106,7 +106,7 @@ import registry
|
|||||||
|
|
||||||
# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
|
# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
|
||||||
# YAML flock with schema-validate + .bak (review item 5). Marks matching running
|
# 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 /
|
# 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).
|
# event arrive via env (MQTT_JID / MQTT_EVENT) — never spliced into source (P1-B).
|
||||||
_MUTATION = r'''
|
_MUTATION = r'''
|
||||||
@@ -116,10 +116,10 @@ _jid = os.environ['MQTT_JID']
|
|||||||
_event = os.environ['MQTT_EVENT']
|
_event = os.environ['MQTT_EVENT']
|
||||||
_now = datetime.now(timezone.utc)
|
_now = datetime.now(timezone.utc)
|
||||||
_changed = False
|
_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':
|
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
|
||||||
_name = s.get('name')
|
_name = s.get('name')
|
||||||
_srv = s.get('tmux_server') or 'default'
|
_srv = s.get('herdr_server') or 'default'
|
||||||
if _event == 'completed':
|
if _event == 'completed':
|
||||||
s['delegate_job_id'] = None
|
s['delegate_job_id'] = None
|
||||||
print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
|
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'] = _now.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
s['terminated_at_epoch'] = int(_now.timestamp())
|
s['terminated_at_epoch'] = int(_now.timestamp())
|
||||||
s['termination_mode'] = 'auto-detected (MQTT ' + _event + ')'
|
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)
|
subprocess.run(_cmd, capture_output=True)
|
||||||
print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv) + ' due to MQTT ' + _event, flush=True)
|
print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv) + ' due to MQTT ' + _event, flush=True)
|
||||||
_changed = True
|
_changed = True
|
||||||
@@ -328,21 +328,21 @@ except NameError:
|
|||||||
drifts = []
|
drifts = []
|
||||||
actions = []
|
actions = []
|
||||||
|
|
||||||
# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
|
# === 현재 herdr 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
|
||||||
tmux_sessions = []
|
herdr_sessions = []
|
||||||
tmux_confirmed = True
|
herdr_confirmed = True
|
||||||
|
|
||||||
# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_SERVER_NAME 포함
|
# YAML 에 등록된 고유한 herdr_server 목록 수집 + 환경변수 HERDR_SERVER_NAME 포함
|
||||||
unique_servers = {'default'}
|
unique_servers = {'default'}
|
||||||
if 'TMUX_SERVER_NAME' in os.environ:
|
if 'HERDR_SERVER_NAME' in os.environ:
|
||||||
unique_servers.add(os.environ['TMUX_SERVER_NAME'])
|
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
srv = s.get('tmux_server') or 'default'
|
srv = s.get('herdr_server') or 'default'
|
||||||
unique_servers.add(srv)
|
unique_servers.add(srv)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for srv in sorted(unique_servers):
|
for srv in sorted(unique_servers):
|
||||||
cmd = ['tmux']
|
cmd = ['herdr']
|
||||||
if srv != 'default':
|
if srv != 'default':
|
||||||
cmd += ['-L', srv]
|
cmd += ['-L', srv]
|
||||||
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
|
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
|
||||||
@@ -352,19 +352,19 @@ try:
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
name, created = line.split('|', 1)
|
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:
|
else:
|
||||||
err = (r.stderr or '').lower()
|
err = (r.stderr or '').lower()
|
||||||
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
|
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
|
||||||
if not is_empty:
|
if not is_empty:
|
||||||
tmux_confirmed = False
|
herdr_confirmed = False
|
||||||
except Exception:
|
except Exception:
|
||||||
tmux_confirmed = False
|
herdr_confirmed = False
|
||||||
|
|
||||||
|
|
||||||
def pane_meta(session, srv):
|
def pane_meta(session, srv):
|
||||||
try:
|
try:
|
||||||
cmd = ['tmux']
|
cmd = ['herdr']
|
||||||
if srv != 'default':
|
if srv != 'default':
|
||||||
cmd += ['-L', srv]
|
cmd += ['-L', srv]
|
||||||
cmd += ['list-panes', '-t', session, '-F',
|
cmd += ['list-panes', '-t', session, '-F',
|
||||||
@@ -376,35 +376,35 @@ def pane_meta(session, srv):
|
|||||||
return None
|
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')}
|
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 ===
|
# === drift A: herdr dead + YAML running → auto-terminate ===
|
||||||
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
|
# herdr 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
|
||||||
if tmux_confirmed:
|
if herdr_confirmed:
|
||||||
for s in yaml_sessions:
|
for s in yaml_sessions:
|
||||||
name = s.get('name')
|
name = s.get('name')
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
# 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
|
# 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
|
||||||
# (없으면 tmux-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
|
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
|
||||||
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
||||||
continue
|
continue
|
||||||
srv = s.get('tmux_server') or 'default'
|
srv = s.get('herdr_server') or 'default'
|
||||||
if (name, srv) not in alive_set:
|
if (name, srv) not in alive_set:
|
||||||
s['status'] = 'terminated'
|
s['status'] = 'terminated'
|
||||||
s['terminated_at'] = now_iso
|
s['terminated_at'] = now_iso
|
||||||
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
|
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 {}
|
pane = s.get('pane') or {}
|
||||||
drifts.append({'class': 'A', 'name': name,
|
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}")
|
actions.append(f"terminated: {name}")
|
||||||
|
|
||||||
# === drift B: tmux alive + not in YAML → auto-register ===
|
# === drift B: herdr alive + not in YAML → auto-register ===
|
||||||
if tmux_confirmed:
|
if herdr_confirmed:
|
||||||
for t in tmux_sessions:
|
for t in herdr_sessions:
|
||||||
name = t['name']
|
name = t['name']
|
||||||
if name in yaml_session_names:
|
if name in yaml_session_names:
|
||||||
continue
|
continue
|
||||||
@@ -434,14 +434,14 @@ if tmux_confirmed:
|
|||||||
entry = {
|
entry = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'status': 'running',
|
'status': 'running',
|
||||||
'tmux_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
'herdr_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
'tmux_session_epoch': t['created'],
|
'herdr_session_epoch': t['created'],
|
||||||
'tmux_server': srv,
|
'herdr_server': srv,
|
||||||
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
||||||
# P2: cwd 인용
|
# P2: cwd 인용
|
||||||
'start_command': f'tmux {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
'start_command': f'herdr {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}',
|
'attach_command': f'herdr {server_opt}attach -t {name}',
|
||||||
'kill_command': f'tmux {server_opt}kill-session -t {name}',
|
'kill_command': f'herdr {server_opt}kill-session -t {name}',
|
||||||
'last_visible_status': 'running',
|
'last_visible_status': 'running',
|
||||||
'last_visible_note': 'auto-registered by monitor',
|
'last_visible_note': 'auto-registered by monitor',
|
||||||
}
|
}
|
||||||
@@ -465,14 +465,14 @@ if tmux_confirmed:
|
|||||||
elif agent == 'cline':
|
elif agent == 'cline':
|
||||||
entry['child_pid'] = 0
|
entry['child_pid'] = 0
|
||||||
entry['cline_conversation_id_own'] = None
|
entry['cline_conversation_id_own'] = None
|
||||||
d.setdefault('tmux_sessions', []).append(entry)
|
d.setdefault('herdr_sessions', []).append(entry)
|
||||||
yaml_session_names.add(name)
|
yaml_session_names.add(name)
|
||||||
drifts.append({'class': 'B', 'name': 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}")
|
actions.append(f"registered: {name}")
|
||||||
|
|
||||||
# === drift C: claude 새 session id materialize (per-row own id) ===
|
# === 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'):
|
if not s.get('name', '').endswith('-creator-claude'):
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
@@ -507,7 +507,7 @@ for s in d.get('tmux_sessions', []):
|
|||||||
actions.append(f"updated session id: {sid}")
|
actions.append(f"updated session id: {sid}")
|
||||||
|
|
||||||
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
|
# === 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'):
|
if not s.get('name', '').endswith('-creator-agy'):
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
@@ -531,7 +531,7 @@ for s in d.get('tmux_sessions', []):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
|
# === 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'):
|
if not s.get('name', '').endswith('-creator-hermes'):
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
@@ -556,7 +556,7 @@ for s in d.get('tmux_sessions', []):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
|
# === 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'):
|
if not s.get('name', '').endswith('-creator-cline'):
|
||||||
continue
|
continue
|
||||||
if s.get('status') != 'running':
|
if s.get('status') != 'running':
|
||||||
@@ -630,8 +630,8 @@ if cn.get('session_id'):
|
|||||||
result = {
|
result = {
|
||||||
'timestamp': now_iso,
|
'timestamp': now_iso,
|
||||||
'yaml_path': yaml_path,
|
'yaml_path': yaml_path,
|
||||||
'tmux_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in tmux_sessions),
|
'herdr_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in herdr_sessions),
|
||||||
'tmux_confirmed': tmux_confirmed,
|
'herdr_confirmed': herdr_confirmed,
|
||||||
'drifts': drifts,
|
'drifts': drifts,
|
||||||
'actions': actions,
|
'actions': actions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-resume
|
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
|
version: 1.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
environments: [terminal, tmux]
|
environments: [terminal, herdr]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
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]
|
related_skills: [multi-agent-mux-create, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
|
||||||
prereq_skills: [multi-agent-mux-create]
|
prereq_skills: [multi-agent-mux-create]
|
||||||
---
|
---
|
||||||
@@ -16,18 +16,18 @@ metadata:
|
|||||||
# Multi-Agent Resume — Reattach to a Saved Conversation
|
# 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).
|
> **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`.
|
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
|
||||||
|
|
||||||
## What this skill does
|
## 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:
|
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>`.
|
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. **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.
|
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. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
|
3. **herdr is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
|
||||||
|
|
||||||
### Resuming a `stopped` session (`stopped → running`)
|
### Resuming a `stopped` session (`stopped → running`)
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ WORKSPACE=/path/to/project
|
|||||||
AGENT=claude # or agy or hermes
|
AGENT=claude # or agy or hermes
|
||||||
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
|
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
|
source .agents/skills/lib.sh
|
||||||
|
|
||||||
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
|
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
|
||||||
@@ -76,12 +76,12 @@ if [ -z "$UUID" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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.
|
# 2. If herdr is alive, attach. Done.
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||||
echo "tmux '$SESSION_NAME' already running. Attaching..."
|
echo "herdr '$SESSION_NAME' already running. Attaching..."
|
||||||
exec tmux attach -t "$SESSION_NAME"
|
exec herdr attach -t "$SESSION_NAME"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Resolve isolation settings for this session (T4/T5 re-apply)
|
# 3. Resolve isolation settings for this session (T4/T5 re-apply)
|
||||||
@@ -108,7 +108,7 @@ try:
|
|||||||
d = yaml.safe_load(f) or {}
|
d = yaml.safe_load(f) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
print(json.dumps(s.get('isolation') or {}))
|
print(json.dumps(s.get('isolation') or {}))
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
@@ -138,20 +138,20 @@ if [ -n "$ISO_ARGS" ]; then
|
|||||||
CMD_FULL="$CMD_FULL $ISO_ARGS"
|
CMD_FULL="$CMD_FULL $ISO_ARGS"
|
||||||
fi
|
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
|
case "$AGENT" in
|
||||||
claude)
|
claude)
|
||||||
if [ -z "$ISO_ROOT" ] && [ -x "$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude" ]; then
|
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
|
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
|
fi
|
||||||
eval "$START_CMD"
|
eval "$START_CMD"
|
||||||
# auto-handle trust / bypass dialogs
|
# auto-handle trust / bypass dialogs
|
||||||
handle_startup_dialogs "$SESSION_NAME" 20
|
handle_startup_dialogs "$SESSION_NAME" 20
|
||||||
;;
|
;;
|
||||||
agy|hermes|cline)
|
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
|
esac
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
|||||||
--session "$SESSION_NAME" --uuid "$UUID"
|
--session "$SESSION_NAME" --uuid "$UUID"
|
||||||
|
|
||||||
# 5. Attach
|
# 5. Attach
|
||||||
tmux attach -t "$SESSION_NAME"
|
herdr attach -t "$SESSION_NAME"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
@@ -175,21 +175,21 @@ tmux attach -t "$SESSION_NAME"
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. tmux alive with the right cmd
|
# 1. herdr alive with the right cmd
|
||||||
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}'
|
||||||
|
|
||||||
# 2. agent-sessions.yaml updated
|
# 2. agent-sessions.yaml updated
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import yaml
|
import yaml
|
||||||
d = yaml.safe_load(open('.mam/agent-sessions.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' status: {s[\"status\"]}')
|
||||||
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
|
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
|
||||||
"
|
"
|
||||||
|
|
||||||
# 3. TUI shows resumed conversation (capture-pane to verify)
|
# 3. TUI shows resumed conversation (capture-pane to verify)
|
||||||
sleep 5
|
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)
|
# 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`
|
- **No saved session yet** → `multi-agent-mux-create`
|
||||||
- **Killing an existing session** → `multi-agent-mux-stop`
|
- **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
|
exit 1
|
||||||
fi
|
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.
|
# 2. If herdr is alive, print warning or attach.
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||||
echo "tmux '$SESSION_NAME' already running."
|
echo "herdr '$SESSION_NAME' already running."
|
||||||
# Just update YAML to make sure it's set to running
|
# Just update YAML to make sure it's set to running
|
||||||
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
||||||
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
|
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
|
||||||
@@ -72,7 +72,7 @@ try:
|
|||||||
d = yaml.safe_load(f) or {}
|
d = yaml.safe_load(f) or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
print(json.dumps(s.get('isolation') or {}))
|
print(json.dumps(s.get('isolation') or {}))
|
||||||
raise SystemExit(0)
|
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"
|
echo "Re-applying isolation: root=$ISO_ROOT env=$ISO_ENV args=$ISO_ARGS"
|
||||||
fi
|
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"
|
RESOLVED_BIN="$AGENT"
|
||||||
if [ "$AGENT" = "cline" ]; then
|
if [ "$AGENT" = "cline" ]; then
|
||||||
if command -v cline >/dev/null 2>&1; 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"
|
CMD_FULL="$CMD_FULL $ISO_ARGS"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Spawn new agent session via herdr
|
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||||
herdr workspace create --label "${TMUX_SERVER_NAME:-default}" --cwd "$WORKSPACE" >/dev/null 2>&1 || true
|
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
|
||||||
herdr agent start "$SESSION_NAME" --workspace "${TMUX_SERVER_NAME:-default}" --cwd "$WORKSPACE" -- $CMD_FULL
|
|
||||||
if [ "$AGENT" = "claude" ]; then
|
if [ "$AGENT" = "claude" ]; then
|
||||||
# auto-handle trust / bypass dialogs
|
# auto-handle trust / bypass dialogs
|
||||||
handle_startup_dialogs "$SESSION_NAME" 20
|
handle_startup_dialogs "$SESSION_NAME" 20
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ done
|
|||||||
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
||||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
[ -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 명시)
|
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||||
if [ -z "$AGENT" ]; then
|
if [ -z "$AGENT" ]; then
|
||||||
@@ -48,8 +48,8 @@ fi
|
|||||||
|
|
||||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
# 새 tmux pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
|
# 새 herdr pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
|
||||||
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)
|
||||||
PANE_PID="${PANE_PID:-}"
|
PANE_PID="${PANE_PID:-}"
|
||||||
CHILD_PID=0
|
CHILD_PID=0
|
||||||
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then
|
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
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
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:
|
if s.get('name') == name:
|
||||||
print(s.get('delegate_job_id', '') or '')
|
print(s.get('delegate_job_id', '') or '')
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -77,7 +77,7 @@ now = os.environ['NOW_ISO']
|
|||||||
pane_pid = os.environ.get('PANE_PID', '')
|
pane_pid = os.environ.get('PANE_PID', '')
|
||||||
|
|
||||||
target = None
|
target = None
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
target = s
|
target = s
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-status
|
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
|
version: 1.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
environments: [terminal, tmux]
|
environments: [terminal, herdr]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
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]
|
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]
|
prereq_skills: [multi-agent-mux-create, multi-agent-mux-monitor]
|
||||||
---
|
---
|
||||||
@@ -16,19 +16,19 @@ metadata:
|
|||||||
# Multi-Agent Status — Read-Only Instant Snapshot
|
# 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).
|
> **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`.
|
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
|
||||||
|
|
||||||
## What this skill does
|
## 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).
|
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
|
## Pre-flight
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
command -v tmux
|
command -v herdr
|
||||||
command -v python3
|
command -v python3
|
||||||
test -f .mam/agent-sessions.yaml
|
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
|
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
|
2. Loads `agent-sessions.yaml` (read-only) to enrich the table
|
||||||
3. For each row in `tmux_sessions[]`:
|
3. For each row in `herdr_sessions[]`:
|
||||||
- tmux alive? (via `tmux has-session -t <name>`)
|
- herdr alive? (via `herdr has-session -t <name>`)
|
||||||
- pane cmd, cwd (via `tmux list-panes`)
|
- 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/...`)
|
- 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`)
|
5. Prints a table (default) or JSON (with `--json`)
|
||||||
|
|
||||||
## Output format (default = aligned table)
|
## 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-claude default running alive claude yes - - -
|
||||||
lab-landing-page-creator-agy default terminated dead agy yes 5fe09ba8 completed -
|
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
|
```json
|
||||||
{
|
{
|
||||||
"yaml_path": "...",
|
"yaml_path": "...",
|
||||||
"tmux_sessions_alive": ["..."],
|
"herdr_sessions_alive": ["..."],
|
||||||
"yaml_entries": [...],
|
"yaml_entries": [...],
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
"name": "lab-landing-page-creator-claude",
|
"name": "lab-landing-page-creator-claude",
|
||||||
"yaml_status": "running",
|
"yaml_status": "running",
|
||||||
"tmux_alive": true,
|
"herdr_alive": true,
|
||||||
"pane_cmd": "claude",
|
"pane_cmd": "claude",
|
||||||
"pane_cwd": "/home/.../refer_landing_page",
|
"pane_cwd": "/home/.../refer_landing_page",
|
||||||
"resume_uuid_on_disk": true,
|
"resume_uuid_on_disk": true,
|
||||||
@@ -85,7 +85,7 @@ lab-paper-pdf2md-creator-claude default running alive clau
|
|||||||
{
|
{
|
||||||
"name": "lab-landing-page-creator-agy",
|
"name": "lab-landing-page-creator-agy",
|
||||||
"yaml_status": "terminated",
|
"yaml_status": "terminated",
|
||||||
"tmux_alive": false,
|
"herdr_alive": false,
|
||||||
"drift": "yaml-says-terminated-but-disk-uuid-still-present"
|
"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 |
|
| 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. |
|
| `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` | 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." |
|
| `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." |
|
| `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. |
|
| `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
|
## 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.
|
- **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).
|
- **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.
|
- **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
|
if [ "$JSON" = "1" ]; then
|
||||||
# D8: --json historically only carried reconcile.sh's drift subset (timestamp/
|
# 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
|
# 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
|
# only this script's text-mode block computed. Fixed additively below via a
|
||||||
# 'sessions_detail' key — every existing key is passed through untouched, so
|
# 'sessions_detail' key — every existing key is passed through untouched, so
|
||||||
@@ -42,7 +42,7 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
d = {}
|
d = {}
|
||||||
|
|
||||||
alive = set(drift.get('tmux_sessions_alive', []))
|
alive = set(drift.get('herdr_sessions_alive', []))
|
||||||
drift_by_name = {}
|
drift_by_name = {}
|
||||||
for dr in drift.get('drifts', []):
|
for dr in drift.get('drifts', []):
|
||||||
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
||||||
@@ -91,9 +91,9 @@ def get_job_status(s):
|
|||||||
|
|
||||||
|
|
||||||
sessions_detail = []
|
sessions_detail = []
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
name = s.get('name', '?')
|
name = s.get('name', '?')
|
||||||
server = s.get('tmux_server') or 'default'
|
server = s.get('herdr_server') or 'default'
|
||||||
jid, jstatus = get_job_status(s)
|
jid, jstatus = get_job_status(s)
|
||||||
pane = s.get('pane') or {}
|
pane = s.get('pane') or {}
|
||||||
sessions_detail.append({
|
sessions_detail.append({
|
||||||
@@ -103,7 +103,7 @@ for s in d.get('tmux_sessions', []):
|
|||||||
'name': name,
|
'name': name,
|
||||||
'server': server,
|
'server': server,
|
||||||
'status': s.get('status', '?'),
|
'status': s.get('status', '?'),
|
||||||
'tmux_alive': f"{name}|{server}" in alive,
|
'herdr_alive': f"{name}|{server}" in alive,
|
||||||
'cmd': pane.get('cmd'),
|
'cmd': pane.get('cmd'),
|
||||||
'role': s.get('role'),
|
'role': s.get('role'),
|
||||||
'resume_state': resume_on_disk(s),
|
'resume_state': resume_on_disk(s),
|
||||||
@@ -138,7 +138,7 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
d = {}
|
d = {}
|
||||||
|
|
||||||
alive = set(drift.get('tmux_sessions_alive', []))
|
alive = set(drift.get('herdr_sessions_alive', []))
|
||||||
drift_by_name = {}
|
drift_by_name = {}
|
||||||
for dr in drift.get('drifts', []):
|
for dr in drift.get('drifts', []):
|
||||||
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
|
||||||
@@ -188,8 +188,8 @@ def get_job_status(s):
|
|||||||
return (jid, 'unknown')
|
return (jid, 'unknown')
|
||||||
|
|
||||||
|
|
||||||
sessions = d.get('tmux_sessions', [])
|
sessions = d.get('herdr_sessions', [])
|
||||||
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['tmux_confirmed']})")
|
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
|
||||||
print("=" * 136)
|
print("=" * 136)
|
||||||
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
|
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
|
||||||
print("-" * 136)
|
print("-" * 136)
|
||||||
@@ -197,7 +197,7 @@ if not sessions:
|
|||||||
print("(no sessions registered)")
|
print("(no sessions registered)")
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
name = s.get('name', '?')
|
name = s.get('name', '?')
|
||||||
server = s.get('tmux_server') or 'default'
|
server = s.get('herdr_server') or 'default'
|
||||||
status = s.get('status', '?')
|
status = s.get('status', '?')
|
||||||
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
||||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
---
|
---
|
||||||
name: multi-agent-mux-stop
|
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
|
version: 1.0.0
|
||||||
author: godopu
|
author: godopu
|
||||||
license: MIT
|
license: MIT
|
||||||
platforms: [linux, macos]
|
platforms: [linux, macos]
|
||||||
environments: [terminal, tmux]
|
environments: [terminal, herdr]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
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]
|
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor]
|
||||||
prereq_skills: [multi-agent-mux-create, multi-agent-mux-resume]
|
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).
|
> **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`.
|
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
|
||||||
|
|
||||||
## What this skill does
|
## 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 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**:
|
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.
|
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.
|
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.
|
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 "
|
python3 -c "
|
||||||
import yaml
|
import yaml
|
||||||
d = yaml.safe_load(open('$AGENT_SESSIONS_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:
|
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.')
|
print('NOT in YAML — refusing to stop (no audit trail). Use multi-agent-mux-create first, or pass --force-no-yaml.')
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
@@ -53,7 +53,7 @@ if '$SESSION_NAME' not in names:
|
|||||||
ALREADY=$(python3 -c "
|
ALREADY=$(python3 -c "
|
||||||
import yaml
|
import yaml
|
||||||
d = yaml.safe_load(open('$AGENT_SESSIONS_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'))
|
print(s.get('status', 'unknown'))
|
||||||
")
|
")
|
||||||
if [ "$ALREADY" = "stopped" ]; then
|
if [ "$ALREADY" = "stopped" ]; then
|
||||||
@@ -95,7 +95,7 @@ If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `termi
|
|||||||
The script:
|
The script:
|
||||||
1. Verifies the session is in agent-sessions.yaml
|
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
|
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
|
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)
|
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
|
6. Updates the YAML entry and SQLite database atomically
|
||||||
@@ -104,21 +104,21 @@ The script:
|
|||||||
## Pitfalls
|
## 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.
|
- **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.
|
- **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
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. tmux gone
|
# 1. herdr gone
|
||||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux gone"
|
herdr has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: herdr gone"
|
||||||
|
|
||||||
# 2. YAML has stopped entry
|
# 2. YAML has stopped entry
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import yaml
|
import yaml
|
||||||
d = yaml.safe_load(open('$AGENT_SESSIONS_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['status'] == 'stopped', f'expected stopped, got {s[\"status\"]}'
|
||||||
assert s.get('stopped_at'), 'missing stopped_at'
|
assert s.get('stopped_at'), 'missing stopped_at'
|
||||||
print(f'OK: stopped at {s[\"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
|
## When NOT to use this skill
|
||||||
|
|
||||||
- **Just detaching** → `tmux detach` (Ctrl-B d) or just close the terminal. The tmux session keeps running.
|
- **Just detaching** → `herdr detach` (Ctrl-B d) or just close the terminal. The herdr 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.
|
- **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`.
|
- **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|hard] [--purge-conversation] [--yes]
|
||||||
#
|
#
|
||||||
# mode:
|
# mode:
|
||||||
# soft — YAML 을 status=archived 로 마크, tmux 세션은 그대로 둠 (P1-A:
|
# soft — YAML 을 status=archived 로 마크, herdr 세션은 그대로 둠 (P1-A:
|
||||||
# terminated 는 tmux 가 실제로 죽은 상태에만 사용)
|
# terminated 는 herdr 가 실제로 죽은 상태에만 사용)
|
||||||
# hard — tmux kill-session + YAML status=terminated
|
# hard — herdr kill-session + YAML status=terminated
|
||||||
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
|
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
|
||||||
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
|
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
|
||||||
# agent_identities 를 참조하지 않음. resume 불가.
|
# agent_identities 를 참조하지 않음. resume 불가.
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
|
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
|
||||||
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
@@ -70,8 +70,8 @@ done
|
|||||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
|
[ -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; }
|
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||||
|
|
||||||
TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||||
export TMUX_SERVER_NAME
|
export HERDR_SERVER_NAME
|
||||||
|
|
||||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
|
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
|
||||||
if [ -z "$AGENT" ]; then
|
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
|
import sys, os, json
|
||||||
name = os.environ['SESSION_NAME']
|
name = os.environ['SESSION_NAME']
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
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:
|
if s.get('name') == name:
|
||||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||||
jid = s.get('delegate_job_id', '') or ''
|
jid = s.get('delegate_job_id', '') or ''
|
||||||
@@ -131,17 +131,17 @@ fi
|
|||||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
NOW_EPOCH=$(date +%s)
|
NOW_EPOCH=$(date +%s)
|
||||||
|
|
||||||
# tmux 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
|
# herdr 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
|
||||||
TMUX_ALIVE=0
|
HERDR_ALIVE=0
|
||||||
LAST_STATUS=""
|
LAST_STATUS=""
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||||
TMUX_ALIVE=1
|
HERDR_ALIVE=1
|
||||||
LAST_STATUS=$(tmux capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
|
LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --capture-id: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때).
|
# --capture-id: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때).
|
||||||
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -> tier-3(cache)
|
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -> tier-3(cache)
|
||||||
# 를 알아서 시도하므로 tmux 생사와 무관하게 동작.
|
# 를 알아서 시도하므로 herdr 생사와 무관하게 동작.
|
||||||
CAPTURED_UUID=""
|
CAPTURED_UUID=""
|
||||||
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||||
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true)
|
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: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL).
|
||||||
graceful_stop() {
|
graceful_stop() {
|
||||||
local pane_pid exitkey
|
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
|
case "$AGENT" in
|
||||||
claude) exitkey="/exit" ;;
|
claude) exitkey="/exit" ;;
|
||||||
agy) exitkey="Exit" ;;
|
agy) exitkey="Exit" ;;
|
||||||
@@ -168,14 +168,14 @@ graceful_stop() {
|
|||||||
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
|
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"
|
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
|
_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"
|
echo "graceful: exited cleanly"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "graceful: still alive → kill-session (SIGTERM)"
|
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
|
_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"
|
echo "graceful: terminated after kill-session"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -183,22 +183,22 @@ graceful_stop() {
|
|||||||
[ -n "$pane_pid" ] && kill -9 "$pane_pid" 2>/dev/null || true
|
[ -n "$pane_pid" ] && kill -9 "$pane_pid" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# tmux 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
|
# herdr 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
|
||||||
if [ "$GRACEFUL" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
|
if [ "$GRACEFUL" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
|
||||||
graceful_stop
|
graceful_stop
|
||||||
elif [ "$TMUX_ALIVE" = "1" ]; then
|
elif [ "$HERDR_ALIVE" = "1" ]; then
|
||||||
tmux kill-session -t "$SESSION_NAME"
|
herdr kill-session -t "$SESSION_NAME"
|
||||||
echo "killed tmux: $SESSION_NAME"
|
echo "killed herdr: $SESSION_NAME"
|
||||||
else
|
else
|
||||||
echo "tmux already dead, just updating YAML"
|
echo "herdr already dead, just updating YAML"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Purge pre-gate: 레코드 제거는 tmux 사망이 확인된 경우에만 허용한다.
|
# Purge pre-gate: 레코드 제거는 herdr 사망이 확인된 경우에만 허용한다.
|
||||||
# (kill 체인은 best-effort — 세션이 살아남으면 monitor drift-B 가
|
# (kill 체인은 best-effort — 세션이 살아남으면 monitor drift-B 가
|
||||||
# 레코드 없는 세션을 running 으로 자동 재등록해 purge 가 조용히 뒤집힌다)
|
# 레코드 없는 세션을 running 으로 자동 재등록해 purge 가 조용히 뒤집힌다)
|
||||||
if [ "$PURGE" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
|
if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
|
||||||
_wait_session_gone "$SESSION_NAME" 5 || true # SIGKILL 폴백의 비동기 회수 윈도우 흡수
|
_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 "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
|
||||||
echo " Refusing registry removal — records preserved (no state was modified)." >&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
|
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()
|
captured = os.environ.get('CAPTURED_UUID', '').strip()
|
||||||
|
|
||||||
target = None
|
target = None
|
||||||
for s in d.get('tmux_sessions', []):
|
for s in d.get('herdr_sessions', []):
|
||||||
if s.get('name') == name:
|
if s.get('name') == name:
|
||||||
target = s
|
target = s
|
||||||
break
|
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)
|
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
|
||||||
|
|
||||||
if purge:
|
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:
|
if purge_uuid:
|
||||||
print(f"removed: {name} (registry entry fully purged from YAML+DB)", flush=True)
|
print(f"removed: {name} (registry entry fully purged from YAML+DB)", flush=True)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user