Compare commits
5
Commits
507ac1847b
...
6df4b03661
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6df4b03661 | ||
|
|
cccc30a8ac | ||
|
|
ff7a2873f9 | ||
|
|
30e606b0fa | ||
|
|
42b54d7643 |
+498
-120
@@ -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)
|
||||||
@@ -22,9 +22,16 @@ if [ -z "${BASH_VERSION:-}" ]; then
|
|||||||
return 1 2>/dev/null || exit 1
|
return 1 2>/dev/null || exit 1
|
||||||
fi
|
fi
|
||||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORKSPACE_ROOT="$(cd "$SKILL_DIR/../.." && pwd)"
|
WORKSPACE_ROOT="${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="$PATH:$dir"
|
||||||
|
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,91 +42,435 @@ 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() {
|
||||||
if [ -z "${_REAL_TMUX_PATH:-}" ] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SHIM_DIR_PATTERN}"* ]] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
|
_REAL_HERDR_PATH="herdr"
|
||||||
local dir save_ifs="$IFS"
|
export _REAL_HERDR_PATH
|
||||||
_REAL_TMUX_PATH=""
|
|
||||||
IFS=:
|
|
||||||
for dir in $PATH; do
|
|
||||||
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]] && [ -x "$dir/tmux" ]; then
|
|
||||||
_REAL_TMUX_PATH="$dir/tmux"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
IFS="$save_ifs"
|
|
||||||
if [ -z "$_REAL_TMUX_PATH" ]; then
|
|
||||||
_REAL_TMUX_PATH="tmux"
|
|
||||||
fi
|
|
||||||
export _REAL_TMUX_PATH
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_init_tmux_isolation() {
|
_init_herdr_isolation() {
|
||||||
_resolve_real_tmux_path
|
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
mkdir -p "$wrapper_dir"
|
||||||
local wrapper_dir="${TMPDIR:-/tmp}${_TMUX_SHIM_DIR_PATTERN}${TMUX_SERVER_NAME}"
|
if [ -x "$wrapper_dir/herdr" ]; then
|
||||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||||
mkdir -p "$wrapper_dir"
|
|
||||||
cat <<EOF > "$wrapper_dir/tmux"
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
if [ -z "\${TMUX_SERVER_NAME:-}" ] || [ "\$TMUX_SERVER_NAME" = "default" ]; then
|
|
||||||
exec "$_REAL_TMUX_PATH" "\$@"
|
|
||||||
else
|
|
||||||
exec "$_REAL_TMUX_PATH" -L "\$TMUX_SERVER_NAME" "\$@"
|
|
||||||
fi
|
|
||||||
EOF
|
|
||||||
chmod +x "$wrapper_dir/tmux"
|
|
||||||
export PATH="$wrapper_dir:$PATH"
|
export PATH="$wrapper_dir:$PATH"
|
||||||
fi
|
fi
|
||||||
else
|
return 0
|
||||||
# 격리 비활성화 시 shim 자동 cleanup (PATH에서 제거)
|
fi
|
||||||
local new_path="" dir save_ifs="$IFS"
|
|
||||||
IFS=:
|
local tmp_file
|
||||||
for dir in $PATH; do
|
tmp_file=$(mktemp "$wrapper_dir/herdr.XXXXXX")
|
||||||
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
|
cat <<'EOF' > "$tmp_file"
|
||||||
if [ -z "$new_path" ]; then
|
#!/usr/bin/env bash
|
||||||
new_path="$dir"
|
# Herdr-to-Herdr translation shim wrapper
|
||||||
else
|
set -euo pipefail
|
||||||
new_path="$new_path:$dir"
|
|
||||||
fi
|
# Dynamic real herdr path resolution to prevent infinite recursion
|
||||||
fi
|
_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)
|
||||||
|
|
||||||
|
# Support parsing -L <server> before the subcommand
|
||||||
|
while [ "${1:-}" = "-L" ]; do
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "herdr shim: -L requires an argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export HERDR_SERVER_NAME="$2"
|
||||||
|
shift 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Herdr's real isolation boundary is `--session <name>` (a whole separate
|
||||||
|
# server + socket, like tmux `-L`) — NOT `workspace create --label`, which is
|
||||||
|
# just a named subdivision inside ONE server and provides no actual isolation
|
||||||
|
# (agent/pane commands are server-global regardless of workspace). When
|
||||||
|
# HERDR_SERVER_NAME names a non-default session, make sure its headless server
|
||||||
|
# is actually running, then scope every real herdr call to it via `--session`.
|
||||||
|
_MAM_SESSION="${HERDR_SERVER_NAME:-default}"
|
||||||
|
if [ "$_MAM_SESSION" = "default" ]; then
|
||||||
|
_MAM_SESSION=""
|
||||||
|
else
|
||||||
|
_mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c "
|
||||||
|
import sys, json, os
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
name = os.environ.get('MAM_SESS', '')
|
||||||
|
print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0')
|
||||||
|
except Exception:
|
||||||
|
print('0')
|
||||||
|
" 2>/dev/null || echo "0")
|
||||||
|
if [ "$_mam_session_running" != "1" ]; then
|
||||||
|
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
||||||
|
# blocks a normal interactive `herdr --session <name>` launch from inside
|
||||||
|
# an existing herdr pane (which is how MAM's own agents usually run).
|
||||||
|
nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
for _mam_wait_i in $(seq 1 40); do
|
||||||
|
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
||||||
|
sleep 0.25
|
||||||
done
|
done
|
||||||
IFS="$save_ifs"
|
|
||||||
export PATH="$new_path"
|
|
||||||
fi
|
fi
|
||||||
}
|
fi
|
||||||
|
|
||||||
mam_tmux() {
|
_real_herdr() {
|
||||||
_resolve_real_tmux_path
|
if [ -n "$_MAM_SESSION" ]; then
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
"$REAL_HERDR" --session "$_MAM_SESSION" "$@"
|
||||||
"$_REAL_TMUX_PATH" -L "$TMUX_SERVER_NAME" "$@"
|
|
||||||
else
|
else
|
||||||
"$_REAL_TMUX_PATH" "$@"
|
"$REAL_HERDR" "$@"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
_tmux() {
|
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
_init_tmux_isolation
|
cmd="${1:-}"
|
||||||
mam_tmux "$@"
|
if [ -z "$cmd" ]; then
|
||||||
|
echo "herdr shim: no command specified" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
has-session)
|
||||||
|
sess=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
_real_herdr agent get "$sess" >/dev/null 2>&1
|
||||||
|
;;
|
||||||
|
new-session)
|
||||||
|
name="" ws="" run_cmd=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-s)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -s requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
name="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-c)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -c requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ws="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-d|-x|-y) shift ;;
|
||||||
|
*) run_cmd="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$run_cmd" ]; then
|
||||||
|
run_cmd="${SHELL:-/bin/bash}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Isolation now comes from `_MAM_SESSION` (a real, separate herdr
|
||||||
|
# session/server) rather than a workspace label match — just create a
|
||||||
|
# fresh workspace inside whatever session is active (default or
|
||||||
|
# isolated) and place the agent there. No cross-session label lookup
|
||||||
|
# needed since `--session` already scopes everything.
|
||||||
|
ws_id=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
print(d.get('result', {}).get('workspace', {}).get('workspace_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
")
|
||||||
|
ws_id="${ws_id:-w1}"
|
||||||
|
|
||||||
|
eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $env_flags -- $final_cmd"
|
||||||
|
;;
|
||||||
|
kill-session)
|
||||||
|
sess=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# NOTE: herdr's `session` verb operates on whole server instances (see
|
||||||
|
# `herdr session list`), not individual agent panes — `session stop/delete
|
||||||
|
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
||||||
|
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
||||||
|
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
if [ -n "$pane_id" ]; then
|
||||||
|
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
list-panes)
|
||||||
|
sess="" format=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-F)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -F requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
format="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
# NOTE: `herdr agent get <target>` returns {"result": {"agent": {...}}} —
|
||||||
|
# there is no top-level "pane" key, and no "pid" field at all (only
|
||||||
|
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
||||||
|
# second call to `pane process-info --pane <pane_id>`.
|
||||||
|
info=$(_real_herdr agent get "$sess" 2>/dev/null || true)
|
||||||
|
pane_id="" cwd="" cmd=""
|
||||||
|
if [ -n "$info" ]; then
|
||||||
|
parsed=$(python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
a = json.loads(sys.stdin.read()).get('result', {}).get('agent', {})
|
||||||
|
except Exception:
|
||||||
|
a = {}
|
||||||
|
print(a.get('pane_id', ''))
|
||||||
|
print(a.get('cwd', ''))
|
||||||
|
print(a.get('agent', ''))
|
||||||
|
" <<< "$info")
|
||||||
|
pane_id=$(sed -n '1p' <<< "$parsed")
|
||||||
|
cwd=$(sed -n '2p' <<< "$parsed")
|
||||||
|
cmd=$(sed -n '3p' <<< "$parsed")
|
||||||
|
fi
|
||||||
|
pid=""
|
||||||
|
if [ -n "$pane_id" ]; then
|
||||||
|
case "$format" in
|
||||||
|
*pane_pid*)
|
||||||
|
pid=$(_real_herdr pane process-info --pane "$pane_id" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
pi = json.loads(sys.stdin.read()).get('result', {}).get('process_info', {})
|
||||||
|
fg = pi.get('foreground_processes') or []
|
||||||
|
print(fg[0]['pid'] if fg else pi.get('shell_pid', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
case "$format" in
|
||||||
|
'#{pane_pid}|#{pane_current_path}|#{pane_current_command}')
|
||||||
|
printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd"
|
||||||
|
;;
|
||||||
|
'#{pane_pid}')
|
||||||
|
printf '%s\n' "$pid"
|
||||||
|
;;
|
||||||
|
'#{pane_current_path}')
|
||||||
|
printf '%s\n' "$cwd"
|
||||||
|
;;
|
||||||
|
'#{pane_current_command}')
|
||||||
|
printf '%s\n' "$cmd"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
capture-pane)
|
||||||
|
sess=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
_real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
send-keys)
|
||||||
|
sess="" key=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) key="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
|
||||||
|
key="Enter"
|
||||||
|
fi
|
||||||
|
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
||||||
|
# resolve it via `agent get` first (agent-level commands accept names).
|
||||||
|
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null)
|
||||||
|
if [ -n "$pane_id" ]; then
|
||||||
|
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
set-buffer)
|
||||||
|
text=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-b)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -b requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
buf="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) text="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
echo -n "$text" > "$wrapper_dir/tmp_buffer"
|
||||||
|
;;
|
||||||
|
paste-buffer)
|
||||||
|
sess=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-t)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "Error: -t requires a value" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sess="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -f "$wrapper_dir/tmp_buffer" ]; then
|
||||||
|
_real_herdr agent send "$sess" "$(cat "$wrapper_dir/tmp_buffer")" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
delete-buffer)
|
||||||
|
rm -f "$wrapper_dir/tmp_buffer"
|
||||||
|
;;
|
||||||
|
ls)
|
||||||
|
_real_herdr agent list 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
res = data.get('result', data)
|
||||||
|
for a in res.get('agents', []):
|
||||||
|
try:
|
||||||
|
name = a.get('name') or a.get('agent') or 'unknown'
|
||||||
|
print(f\"{name}|0\")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" || true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
_real_herdr "$cmd" "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$tmp_file"
|
||||||
|
mv -f "$tmp_file" "$wrapper_dir/herdr"
|
||||||
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||||
|
export PATH="$wrapper_dir:$PATH"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
tmux() {
|
mam_herdr() {
|
||||||
_tmux "$@"
|
_init_herdr_isolation
|
||||||
|
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
_herdr() {
|
||||||
|
mam_herdr "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
herdr() {
|
||||||
|
mam_herdr "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# resolve_tmux_server <session_name>
|
# resolve_herdr_server <session_name>
|
||||||
#
|
#
|
||||||
# Query agent-sessions.yaml to find the tmux_server associated with a session.
|
# 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() {
|
||||||
@@ -139,7 +490,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()
|
||||||
@@ -162,24 +513,30 @@ print(json.dumps(d, ensure_ascii=False))
|
|||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_tmux_server() {
|
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
|
||||||
|
# all do `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
|
||||||
|
# returns the isolated herdr *session* name to use for this MAM session row, not
|
||||||
|
# a workspace id. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
|
||||||
|
# generated wrapper) — a workspace label match provides no actual isolation
|
||||||
|
# since agent/pane commands are server-global regardless of workspace.
|
||||||
|
resolve_herdr_workspace() {
|
||||||
local session_name="$1"
|
local session_name="$1"
|
||||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
|
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') or s.get('herdr_server') or 'default')
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
|
print(os.environ.get('HERDR_SERVER_NAME', 'default'))
|
||||||
"
|
"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 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>"
|
||||||
@@ -207,6 +564,10 @@ derive_session_name() {
|
|||||||
fi
|
fi
|
||||||
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
||||||
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
|
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
|
||||||
|
slug="$(printf '%s' "$slug" | sed 's/^-*//')"
|
||||||
|
if [ -z "$slug" ]; then
|
||||||
|
slug="ws"
|
||||||
|
fi
|
||||||
printf '%s-creator-%s' "$slug" "$agent"
|
printf '%s-creator-%s' "$slug" "$agent"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,33 +681,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()
|
||||||
@@ -387,7 +748,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():
|
||||||
@@ -400,9 +761,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')}
|
||||||
@@ -411,7 +772,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}")
|
||||||
@@ -419,7 +780,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:
|
||||||
@@ -432,11 +793,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', '')
|
||||||
@@ -502,7 +863,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])
|
||||||
@@ -571,7 +932,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
|
||||||
@@ -590,7 +951,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)
|
||||||
|
|
||||||
@@ -916,7 +1277,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)
|
||||||
@@ -925,7 +1286,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)
|
||||||
@@ -1042,9 +1403,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
|
||||||
@@ -1053,7 +1414,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)
|
||||||
@@ -1089,7 +1450,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}"
|
||||||
@@ -1103,23 +1464,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
|
||||||
@@ -1158,15 +1519,25 @@ _pane_dialog_open() {
|
|||||||
send_keys_safe() {
|
send_keys_safe() {
|
||||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||||
local marker pre_submit deadline try
|
local marker pre_submit deadline try
|
||||||
# Verification token: last 24 chars of the last non-empty line (multi-line safe).
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
||||||
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | tail -c 24)
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
||||||
|
# the properly-decoded rendered pane text) of the last non-empty line.
|
||||||
|
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')")
|
||||||
|
# Whitespace-normalized copy for matching: some TUIs (e.g. cline's own
|
||||||
|
# message rendering) don't just soft-wrap with a bare newline — they add a
|
||||||
|
# leading-space "hanging indent" on the continuation line too, so removing
|
||||||
|
# only '\n' still leaves an extra space that breaks an exact literal match.
|
||||||
|
# Matching with all whitespace collapsed out sidesteps wrap formatting
|
||||||
|
# entirely, whatever shape it takes.
|
||||||
|
local marker_norm
|
||||||
|
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
||||||
|
|
||||||
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
|
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
|
||||||
|
|
||||||
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
|
||||||
@@ -1176,11 +1547,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
|
||||||
@@ -1188,14 +1559,18 @@ send_keys_safe() {
|
|||||||
pane_content=$(_pane_capture "$sess")
|
pane_content=$(_pane_capture "$sess")
|
||||||
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
||||||
was_popup=1
|
was_popup=1
|
||||||
elif ! printf '%s\n' "$pane_content" | grep -Fq "$marker"; then
|
# Strip ALL whitespace before matching — the rendered pane soft-wraps long
|
||||||
|
# lines at the terminal width (and some TUIs add indentation on the
|
||||||
|
# continuation line too), which can split/reformat the marker across two
|
||||||
|
# visual lines and make a literal grep miss it even though the paste landed.
|
||||||
|
elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then
|
||||||
echo "send_keys_safe: paste not visible ($sess)" >&2
|
echo "send_keys_safe: paste not visible ($sess)" >&2
|
||||||
return 3
|
return 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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")
|
||||||
@@ -1203,7 +1578,7 @@ send_keys_safe() {
|
|||||||
if printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing…|[A-Za-z]+ing\.\.\.|esc to interrupt"; then
|
if printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing…|[A-Za-z]+ing\.\.\.|esc to interrupt"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | grep -Fq "$marker" && [ "$cur_content" != "$pre_submit" ]; then
|
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
|
||||||
return 0
|
return 0
|
||||||
elif [ "$was_popup" = "1" ] && \
|
elif [ "$was_popup" = "1" ] && \
|
||||||
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
||||||
@@ -1224,13 +1599,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
|
||||||
@@ -1239,3 +1614,6 @@ handle_startup_dialogs() {
|
|||||||
done
|
done
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Auto-initialize the herdr translation shim wrapper on library source
|
||||||
|
_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,31 @@ 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).
|
||||||
|
|
||||||
|
Under the hood this now maps to a real, separate herdr **session** (`herdr --session <name>` — its own socket, its own `agent list`/`workspace list`, completely invisible to the default session and vice versa), not just a workspace label inside the same server. `lib.sh`'s shim bootstraps the named session's server headlessly (`herdr --session <name> server`, backgrounded) the first time it's needed, and scopes every subsequent herdr call to it automatically — this headless bootstrap is what lets it work even when the skill itself is running from inside another herdr-managed pane (a plain interactive `herdr --session <name>` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't).
|
||||||
|
|
||||||
### 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 +92,14 @@ 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 server stop` as they will destroy every workspace/agent on that server (including your own workspace sessions if they share the server). (`kill-server`/`kill-session -a` are tmux-era names that don't exist in herdr's real CLI — see Pitfalls below.)
|
||||||
- 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 — this is now backed by a genuinely separate `herdr` session/socket, not merely a workspace label.
|
||||||
|
- To deliberately tear down an *entire* isolated group at once (all its workspaces and agents), use `herdr session stop <HERDR_SERVER_NAME>` followed by `herdr session delete <HERDR_SERVER_NAME>` — this only affects that named session, never the default one.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -107,25 +110,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 +137,24 @@ 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 list-sessions` doesn't exist (real or shimmed) — we just spawned this
|
||||||
|
# session ourselves, so stamp the epoch locally instead of round-tripping herdr.
|
||||||
|
HERDR_EPOCH=$(date +%s)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 +167,13 @@ 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: "HERDR_SERVER_NAME=<herdr_server> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>"
|
||||||
attach_command: "tmux attach -t <SESSION_NAME>"
|
attach_command: "HERDR_SERVER_NAME=<herdr_server> herdr agent attach <SESSION_NAME>"
|
||||||
kill_command: "tmux kill-session -t <SESSION_NAME>"
|
kill_command: "HERDR_SERVER_NAME=<herdr_server> herdr kill-session -t <SESSION_NAME>"
|
||||||
|
# All three require `source .agents/skills/lib.sh` first — `new-session`/`kill-session`
|
||||||
|
# are tmux-compat pseudo-commands the shim translates, and `HERDR_SERVER_NAME` is what
|
||||||
|
# the shim reads to route to the right isolated herdr *session* (real `herdr` has no
|
||||||
|
# env-var-based scoping of its own; `herdr_server: default` needs no prefix at all).
|
||||||
```
|
```
|
||||||
|
|
||||||
`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 +194,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 +205,34 @@ 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 (real native command — no lib.sh needed)
|
||||||
tmux has-session -t "$SESSION_NAME" && echo OK || echo MISSING
|
herdr agent get "$SESSION_NAME" >/dev/null 2>&1 && 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 agent get "$SESSION_NAME" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
a = json.load(sys.stdin)['result']['agent']
|
||||||
|
print(f\"cmd={a['agent']} cwd={a['cwd']}\")
|
||||||
|
"
|
||||||
|
|
||||||
# 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 (real native command)
|
||||||
tmux capture-pane -t "$SESSION_NAME" -p -S -20 # TUI ready = agent banner visible, no dialog text
|
herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = agent banner visible, no dialog text
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `herdr has-session` / `herdr list-panes` / `herdr capture-pane` above are tmux-compat pseudo-commands only understood after `source .agents/skills/lib.sh` (see `Workflow`) — the real `herdr` binary has no such subcommands. The block above uses the real `herdr agent get`/`herdr agent read` equivalents so it also works standalone.
|
||||||
|
|
||||||
## 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 agent attach <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
|
||||||
@@ -79,7 +79,7 @@ fi
|
|||||||
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
|
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
|
||||||
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
|
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
|
||||||
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
|
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
|
||||||
command -v tmux >/dev/null || { echo "ERROR: tmux not installed" >&2; exit 1; }
|
command -v herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; }
|
||||||
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
|
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
|
||||||
|
|
||||||
# Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes)
|
# Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes)
|
||||||
@@ -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,30 +224,18 @@ 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)
|
||||||
local_tmux="tmux"
|
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
# isolation (HERDR_SERVER_NAME picked up by the lib.sh shim), not a
|
||||||
local_tmux="tmux -L $TMUX_SERVER_NAME"
|
# `--workspace <label>` flag (real `herdr agent start --workspace` wants an
|
||||||
fi
|
# actual workspace id like "w2", which HERDR_SERVER_NAME is not).
|
||||||
|
START_CMD="HERDR_SERVER_NAME=${HERDR_SERVER_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||||
case "$AGENT" in
|
|
||||||
claude)
|
|
||||||
if [ "$ISOLATE" != "1" ] && [ -x "$WRAPPER" ]; then
|
|
||||||
START_CMD="$WRAPPER # ~/.local/bin 의 래퍼"
|
|
||||||
else
|
|
||||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
agy|hermes|cline)
|
|
||||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -271,14 +259,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).
|
||||||
@@ -292,9 +280,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'
|
||||||
@@ -302,11 +290,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).
|
||||||
@@ -320,9 +308,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,
|
||||||
@@ -332,8 +320,13 @@ entry = {
|
|||||||
'cwd': os.environ['PANE_CWD'],
|
'cwd': os.environ['PANE_CWD'],
|
||||||
},
|
},
|
||||||
'start_command': os.environ['START_CMD'],
|
'start_command': os.environ['START_CMD'],
|
||||||
'attach_command': f'tmux {server_opt}attach -t {name}',
|
# NOTE: `herdr session attach/stop/delete` operate on whole herdr
|
||||||
'kill_command': f'tmux {server_opt}kill-session -t {name}',
|
# *sessions* (server instances, e.g. "default") — NOT on an individual
|
||||||
|
# agent by its MAM name. Use the lib.sh tmux-compat shim commands
|
||||||
|
# instead (`source .agents/skills/lib.sh` first), scoped via the same
|
||||||
|
# env-var-driven isolation as start_command above.
|
||||||
|
'attach_command': f'HERDR_SERVER_NAME={server_name} herdr agent attach {name}',
|
||||||
|
'kill_command': f'HERDR_SERVER_NAME={server_name} herdr kill-session -t {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
|
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
|
||||||
@@ -389,7 +382,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"
|
||||||
|
|
||||||
@@ -405,7 +398,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
|
||||||
@@ -421,10 +414,6 @@ fi
|
|||||||
trap - EXIT
|
trap - EXIT
|
||||||
echo "agent-sessions.yaml updated"
|
echo "agent-sessions.yaml updated"
|
||||||
echo
|
echo
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
echo "Attach: herdr session attach $SESSION_NAME"
|
||||||
echo "Attach: tmux -L $TMUX_SERVER_NAME attach -t $SESSION_NAME"
|
|
||||||
else
|
|
||||||
echo "Attach: tmux attach -t $SESSION_NAME"
|
|
||||||
fi
|
|
||||||
echo "Delete: use multi-agent-mux-stop skill"
|
echo "Delete: use multi-agent-mux-stop skill"
|
||||||
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -92,4 +92,5 @@ Job lifecycle execution events are persistently mirrored to an append-only log u
|
|||||||
- **Subscribe-Before-Publish**: The subscriber must be running before the agent starts publishing. The `submit` command handles this automatically by launching the subscriber in the background first.
|
- **Subscribe-Before-Publish**: The subscriber must be running before the agent starts publishing. The `submit` command handles this automatically by launching the subscriber in the background first.
|
||||||
- **Fresh job_id Propagation**: Make sure the worker agent receives the correct `JOB_ID` generated for the current run, rather than reusing stale IDs from previous sessions.
|
- **Fresh job_id Propagation**: Make sure the worker agent receives the correct `JOB_ID` generated for the current run, rather than reusing stale IDs from previous sessions.
|
||||||
- **Brief delivery via file path**: For long or complex prompts, write the instructions to a file (e.g. `/tmp/task-brief.md`) and pass a short prompt pointing to the file path to prevent terminal buffer overflows.
|
- **Brief delivery via file path**: For long or complex prompts, write the instructions to a file (e.g. `/tmp/task-brief.md`) and pass a short prompt pointing to the file path to prevent terminal buffer overflows.
|
||||||
|
- **Prompts injected into a live agent session MUST be English, ASCII-only, and short** — this is exactly what `--prompt`/the `instructions` string sent to `run_agent()` end up as. Any Korean (or other non-ASCII) content the task needs to convey must go in a markdown brief file (e.g. `.mam/jobs/<id>/brief.md`, written in Korean is fine) that the injected prompt merely tells the agent to read. Two independent bugs in `send_keys_safe`'s paste-verification (in `lib.sh`) made this matter in practice: (a) its marker was taken with a byte-based `tail -c 24`, which can slice a multi-byte UTF-8 (e.g. Korean) character in half; (b) the rendered pane soft-wraps long lines at the terminal width, which can split the marker across two visual lines. Both are now fixed at the source (character-safe truncation + newline-stripped matching before comparison), but keeping injected prompts short/English/file-referencing is still the cheapest way to avoid ever exercising this edge case at all — it's also simply what `submit`'s own default instruction template already does (see `Core Commands` above).
|
||||||
- **Batch Grouping**: Group non-overlapping tasks into batches to parallelize execution across multiple agent sessions, reducing overhead.
|
- **Batch Grouping**: Group non-overlapping tasks into batches to parallelize execution across multiple agent sessions, reducing overhead.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
#
|
#
|
||||||
# This is a reference wrapper: it shells out to the python scripts that live
|
# This is a reference wrapper: it shells out to the python scripts that live
|
||||||
# next to it. Copy it into your project and customise as needed. It never hard
|
# next to it. Copy it into your project and customise as needed. It never hard
|
||||||
# fails if `claude`/`codex`/`tmux` are missing — it prints what it would run.
|
# fails if `claude`/`codex`/`herdr` are missing — it prints what it would run.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -23,11 +23,20 @@ elif [[ -f "$SCRIPT_DIR/../../.env" ]]; then
|
|||||||
set -a; source "$SCRIPT_DIR/../../.env"; set +a
|
set -a; source "$SCRIPT_DIR/../../.env"; set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Source EARLY (before any herdr usage in run_agent) — this is what turns
|
||||||
|
# plain `herdr` into the tmux-compat shim (herdr() function) and provides
|
||||||
|
# resolve_herdr_workspace/send_keys_safe. Sourcing it late meant the
|
||||||
|
# has-session pre-flight check below used to hit the real herdr binary with
|
||||||
|
# a nonexistent subcommand and always fail.
|
||||||
|
source "$SCRIPT_DIR/../lib.sh"
|
||||||
|
|
||||||
# Pick an interpreter: prefer a project .venv, else python3.
|
# Pick an interpreter: prefer a project .venv, else python3.
|
||||||
pick_python() {
|
pick_python() {
|
||||||
local py_bin
|
local py_bin
|
||||||
if [[ -n "${DELEGATE_JOB_PYTHON:-}" ]]; then
|
if [[ -n "${DELEGATE_JOB_PYTHON:-}" ]]; then
|
||||||
py_bin="$DELEGATE_JOB_PYTHON"
|
py_bin="$DELEGATE_JOB_PYTHON"
|
||||||
|
elif [[ -n "${AGENT_PYTHON_BIN:-}" ]] && [[ -x "$AGENT_PYTHON_BIN" ]]; then
|
||||||
|
py_bin="$AGENT_PYTHON_BIN"
|
||||||
elif [[ -x "${WORKDIR:-.}/.venv/bin/python" ]]; then
|
elif [[ -x "${WORKDIR:-.}/.venv/bin/python" ]]; then
|
||||||
py_bin="${WORKDIR}/.venv/bin/python"
|
py_bin="${WORKDIR}/.venv/bin/python"
|
||||||
elif [[ -x ".venv/bin/python" ]]; then
|
elif [[ -x ".venv/bin/python" ]]; then
|
||||||
@@ -56,7 +65,7 @@ multi-agent-mux-delegate-job <command> [options]
|
|||||||
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
||||||
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
|
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
|
||||||
[--counterpart-role <role_name>] [--strict-role-check]
|
[--counterpart-role <role_name>] [--strict-role-check]
|
||||||
# The skill is tmux-interactive only; --mode print was removed.
|
# The skill is herdr-interactive only; --mode print was removed.
|
||||||
status --job <id> [--registry-dir <dir>]
|
status --job <id> [--registry-dir <dir>]
|
||||||
list [--registry-dir <dir>]
|
list [--registry-dir <dir>]
|
||||||
verify --job <id> --validate <script> [--registry-dir <dir>]
|
verify --job <id> --validate <script> [--registry-dir <dir>]
|
||||||
@@ -66,10 +75,10 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---- arg parsing helpers --------------------------------------------------
|
# ---- arg parsing helpers --------------------------------------------------
|
||||||
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux:claude"
|
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
|
||||||
TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0
|
TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0
|
||||||
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
|
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
|
||||||
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="tmux:hermes"; MAX_ITERATIONS=5
|
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="herdr:hermes"; MAX_ITERATIONS=5
|
||||||
DEFAULT_COUNTERPART_ROLE="Reviewer"
|
DEFAULT_COUNTERPART_ROLE="Reviewer"
|
||||||
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
|
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
|
||||||
STRICT_ROLE_CHECK=0
|
STRICT_ROLE_CHECK=0
|
||||||
@@ -153,8 +162,15 @@ EOF
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
|
wait "$sub_pid" 2>/dev/null
|
||||||
exit 1
|
local sub_exit=$?
|
||||||
|
if [ $sub_exit -eq 0 ]; then
|
||||||
|
sub_ready=1
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
@@ -224,7 +240,7 @@ EOF
|
|||||||
|
|
||||||
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
|
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
|
||||||
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
local job_dir="$REGISTRY_DIR/$JOB_ID"
|
||||||
local clean_session="${current_session#tmux:}"
|
local clean_session="${current_session#herdr:}"
|
||||||
if [[ "$DRY_RUN" != "1" ]]; then
|
if [[ "$DRY_RUN" != "1" ]]; then
|
||||||
mkdir -p "$job_dir"
|
mkdir -p "$job_dir"
|
||||||
cat <<EOF > "$job_dir/brief.md"
|
cat <<EOF > "$job_dir/brief.md"
|
||||||
@@ -267,8 +283,15 @@ EOF
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
|
wait "$sub_pid" 2>/dev/null
|
||||||
exit 1
|
local sub_exit=$?
|
||||||
|
if [ $sub_exit -eq 0 ]; then
|
||||||
|
sub_ready=1
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
@@ -387,51 +410,51 @@ run_agent() {
|
|||||||
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
|
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
|
||||||
# one-shot print mode, because:
|
# one-shot print mode, because:
|
||||||
# - claude -p exits the moment stdin is drained, so there's nothing to
|
# - claude -p exits the moment stdin is drained, so there's nothing to
|
||||||
# `tmux attach` to afterwards.
|
# `herdr session attach` to afterwards.
|
||||||
# - fire-and-forget via wrapper defeats the whole point of the audit log
|
# - fire-and-forget via wrapper defeats the whole point of the audit log
|
||||||
# (you can't tell what happened if the agent crashes mid-turn).
|
# (you can't tell what happened if the agent crashes mid-turn).
|
||||||
# - the job registry already gives us an authoritative completion signal,
|
# - the job registry already gives us an authoritative completion signal,
|
||||||
# so we don't need a wrapper-side exit code to know "done".
|
# so we don't need a wrapper-side exit code to know "done".
|
||||||
# The user attaches with `tmux attach -t <session>` and types follow-up
|
# The user attaches with `herdr session attach <session>` and types follow-up
|
||||||
# prompts themselves. We pre-load the first prompt via stdin and `read`
|
# prompts themselves. We pre-load the first prompt via stdin and `read`
|
||||||
# keeps the pane open after the agent exits so the user can review.
|
# keeps the pane open after the agent exits so the user can review.
|
||||||
if [ "$AGENT" = "human" ]; then
|
if [ "$AGENT" = "human" ]; then
|
||||||
echo "[human agent] complete the task, then run publish_event.py --event completed"
|
echo "[human agent] complete the task, then run publish_event.py --event completed"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
local sess="${target_session#tmux:}"
|
local sess="${target_session#herdr:}"
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == "1" ]]; then
|
if [[ "$DRY_RUN" == "1" ]]; then
|
||||||
echo "[dry-run] would delegate task to running agent '$AGENT' in tmux session '$sess' with instructions:"
|
echo "[dry-run] would delegate task to running agent '$AGENT' in herdr session '$sess' with instructions:"
|
||||||
echo "----"; echo "$instructions"; echo "----"
|
echo "----"; echo "$instructions"; echo "----"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
if ! command -v herdr >/dev/null 2>&1; then
|
||||||
echo "ERROR: this skill requires tmux (interactive agent sessions)." >&2
|
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
|
||||||
echo " Install with: brew install tmux (or your package manager)" >&2
|
echo " Ensure herdr is installed and executable." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local _tmux="tmux"
|
# Auto-resolve isolation the same way resume/stop/create do — don't rely on
|
||||||
if [ -n "${TMUX_SERVER_NAME:-}" ]; then
|
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
|
||||||
_tmux="tmux -L $TMUX_SERVER_NAME"
|
# delegation reach an agent living in an isolated herdr session (e.g. one
|
||||||
fi
|
# created with --herdr-server) instead of silently looking in "default".
|
||||||
|
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$sess")"
|
||||||
|
|
||||||
if ! $_tmux has-session -t "$sess" 2>/dev/null; then
|
if ! herdr has-session -t "$sess" 2>/dev/null; then
|
||||||
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
|
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
|
||||||
echo " 팁: 'multi-agent-mux-resume' 또는 'multi-agent-mux-create'를 통해 에이전트를 먼저 생성할 수 있습니다." >&2
|
echo " 팁: 'multi-agent-mux-resume' 또는 'multi-agent-mux-create'를 통해 에이전트를 먼저 생성할 수 있습니다." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check role suitability
|
# Check role suitability
|
||||||
source "$SCRIPT_DIR/../lib.sh"
|
|
||||||
local sess_role job_role
|
local sess_role job_role
|
||||||
sess_role=$(SESS_NAME="$sess" MAM_STATE_JSON="$(load_state_json)" "$PY" -c "
|
sess_role=$(SESS_NAME="$sess" MAM_STATE_JSON="$(load_state_json)" "$PY" -c "
|
||||||
import os, json
|
import os, json
|
||||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||||
name = os.environ.get('SESS_NAME')
|
name = os.environ.get('SESS_NAME')
|
||||||
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('role', ''))
|
print(s.get('role', ''))
|
||||||
break
|
break
|
||||||
@@ -483,7 +506,11 @@ else:
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_tmux attach -t $sess)"
|
# NOTE: `herdr session attach` operates on whole herdr *sessions* (server
|
||||||
|
# instances), not an individual agent by its MAM name — `agent attach` is
|
||||||
|
# the real command for that. HERDR_SERVER_NAME is inlined so the printed
|
||||||
|
# command is copy-pasteable in a fresh shell that hasn't sourced lib.sh.
|
||||||
|
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SERVER_NAME=$HERDR_SERVER_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -153,7 +153,11 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
expected_ids: Set[str] = {j["job_id"] for j in jobs}
|
expected_ids: Set[str] = {j["job_id"] for j in jobs}
|
||||||
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
|
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
|
||||||
seqs = {j["job_id"]: int(j.get("last_seq", 0)) for j in jobs}
|
seqs = {}
|
||||||
|
for j in jobs:
|
||||||
|
jid = j["job_id"]
|
||||||
|
last_seq = int(j.get("last_seq", 0))
|
||||||
|
seqs[jid] = max(0, last_seq - 1)
|
||||||
watcher = _Watcher(expected_ids, tokens, seqs)
|
watcher = _Watcher(expected_ids, tokens, seqs)
|
||||||
|
|
||||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||||
|
|||||||
@@ -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,8 +158,8 @@ 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', '').lower() 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,8 +198,8 @@ 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', '').lower():
|
||||||
planner = s.get('name')
|
planner = s.get('name')
|
||||||
break
|
break
|
||||||
print(planner)
|
print(planner)
|
||||||
@@ -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 agent list` (which sessions are alive)
|
||||||
- `tmux list-panes -t <session> ...` (pane cmd, cwd, pid)
|
- `herdr agent get <session>` (pane cmd, cwd)
|
||||||
- `~/.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 agent list` 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 agent list` and `herdr agent get <session>` for each tracked session name (these are real native herdr commands — do not use tmux-era names like `herdr ls`/`herdr list-panes` outside a shell that has sourced `.agents/skills/lib.sh`)
|
||||||
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,12 +9,14 @@
|
|||||||
# --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
|
||||||
|
|
||||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||||
|
source "$LIB_SH"
|
||||||
export WORKSPACE_ROOT
|
export WORKSPACE_ROOT
|
||||||
|
|
||||||
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
||||||
@@ -106,7 +108,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 +118,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_workspace') or 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 +131,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
|
||||||
@@ -316,10 +318,13 @@ except NameError:
|
|||||||
import subprocess
|
import subprocess
|
||||||
d = {}
|
d = {}
|
||||||
try:
|
try:
|
||||||
ws_root = os.environ.get('WORKSPACE_ROOT')
|
lib_sh = os.environ.get('LIB_SH')
|
||||||
if not ws_root:
|
if not lib_sh:
|
||||||
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||||
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
|
if not ws_root:
|
||||||
|
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||||
|
lib_sh = os.path.join(ws_root, '.agents/skills/lib.sh')
|
||||||
|
script = f"source '{lib_sh}' && load_state_json"
|
||||||
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
|
||||||
d = json.loads(out.decode('utf-8'))
|
d = json.loads(out.decode('utf-8'))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -328,21 +333,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_workspace') or 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 +357,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,38 +381,43 @@ 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_workspace') or 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
|
||||||
|
workspace_root = os.environ.get('WORKSPACE_ROOT')
|
||||||
|
if not workspace_root:
|
||||||
|
workspace_root = os.path.abspath(os.path.join(os.path.dirname(yaml_path), '..'))
|
||||||
|
if os.path.exists(os.path.join(workspace_root, '.mam', f"purging-{name}")):
|
||||||
|
continue
|
||||||
if name.endswith('-creator-claude'):
|
if name.endswith('-creator-claude'):
|
||||||
agent = 'claude'
|
agent = 'claude'
|
||||||
elif name.endswith('-creator-agy'):
|
elif name.endswith('-creator-agy'):
|
||||||
@@ -434,14 +444,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}agent attach {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 +475,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 +517,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 +541,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 +566,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 +640,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,
|
||||||
}
|
}
|
||||||
@@ -643,7 +653,7 @@ if not actions:
|
|||||||
PYEOF
|
PYEOF
|
||||||
|
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
|
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
|
||||||
else
|
else
|
||||||
printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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 agent attach "$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
|
||||||
|
|
||||||
@@ -160,8 +160,9 @@ esac
|
|||||||
bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
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 (real native command — "attach" isn't in the lib.sh tmux-compat shim,
|
||||||
tmux attach -t "$SESSION_NAME"
|
# and real herdr has no `-t` flag here, only a positional target)
|
||||||
|
herdr agent attach "$SESSION_NAME"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
@@ -175,26 +176,35 @@ tmux attach -t "$SESSION_NAME"
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. tmux alive with the right cmd
|
# 1. herdr alive with the right cmd (real native command, no lib.sh needed)
|
||||||
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
|
herdr agent get "$SESSION_NAME" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
a = json.load(sys.stdin)['result']['agent']
|
||||||
|
print(f\"cmd={a['agent']} cwd={a['cwd']}\")
|
||||||
|
"
|
||||||
|
|
||||||
# 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 (real native command, no lib.sh needed)
|
||||||
sleep 5
|
sleep 5
|
||||||
tmux capture-pane -t "$SESSION_NAME" -p -S -30
|
herdr agent read "$SESSION_NAME" --source visible --lines 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `herdr list-panes` / `herdr capture-pane` here are tmux-compat pseudo-commands that only
|
||||||
|
> work after `source .agents/skills/lib.sh` (as done in `Workflow` above) — the real `herdr`
|
||||||
|
> binary doesn't have those subcommands. This block uses the real `herdr agent get`/`herdr agent read`
|
||||||
|
> equivalents instead so it also works standalone.
|
||||||
|
|
||||||
## When NOT to use this skill
|
## When NOT to use this skill
|
||||||
|
|
||||||
- **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 agent attach <name>` (no skill needed)
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ if [ -z "$UUID" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
|
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||||
|
export HERDR_SERVER_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 +73,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 +88,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,22 +121,12 @@ 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
|
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||||
case "$AGENT" in
|
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
|
||||||
claude)
|
if [ "$AGENT" = "claude" ]; then
|
||||||
if [ -z "$ISO_ROOT" ] && [ -x "$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude" ]; then
|
# auto-handle trust / bypass dialogs
|
||||||
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\""
|
handle_startup_dialogs "$SESSION_NAME" 20
|
||||||
else
|
fi
|
||||||
START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
|
||||||
fi
|
|
||||||
eval "$START_CMD"
|
|
||||||
# auto-handle trust / bypass dialogs
|
|
||||||
handle_startup_dialogs "$SESSION_NAME" 20
|
|
||||||
;;
|
|
||||||
agy|hermes|cline)
|
|
||||||
eval "tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Wait for TUI readiness or let it settle
|
# Wait for TUI readiness or let it settle
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ 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")"
|
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||||
|
export HERDR_SERVER_NAME
|
||||||
|
|
||||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||||
if [ -z "$AGENT" ]; then
|
if [ -z "$AGENT" ]; then
|
||||||
@@ -48,8 +49,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 +62,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 +78,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 agent get <name>`, real native command — the script sources `lib.sh` internally, which is what lets it also spell this as `herdr has-session -t <name>`)
|
||||||
- pane cmd, cwd (via `tmux list-panes`)
|
- pane cmd, cwd (via `herdr agent get <name>`, likewise shimmed as `herdr list-panes` internally)
|
||||||
- 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 multi-agent-mux-stop 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_workspace') or 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,23 +188,23 @@ 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']} (tmux_confirmed={drift['tmux_confirmed']})")
|
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
|
||||||
print("=" * 136)
|
print("=" * 136)
|
||||||
print(f"{'NAME':<44} {'SERVER':<12} {'YAML':<10} {'TMUX':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
|
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
|
||||||
print("-" * 136)
|
print("-" * 136)
|
||||||
if not sessions:
|
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_workspace') or s.get('herdr_server') or 'default'
|
||||||
status = s.get('status', '?')
|
status = s.get('status', '?')
|
||||||
tmux = 'alive' if f"{name}|{server}" in alive else 'dead'
|
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
|
||||||
cmd = (s.get('pane') or {}).get('cmd', '?')
|
cmd = (s.get('pane') or {}).get('cmd', '?')
|
||||||
res = resume_on_disk(s)
|
res = resume_on_disk(s)
|
||||||
jid, jstatus = get_job_status(s)
|
jid, jstatus = get_job_status(s)
|
||||||
drs = ','.join(drift_by_name.get(name, [])) or '-'
|
drs = ','.join(drift_by_name.get(name, [])) or '-'
|
||||||
print(f"{name:<44} {server:<12} {status:<10} {tmux:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
|
print(f"{name:<44} {server:<12} {status:<10} {herdr:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
|
||||||
# drifts not tied to a registered row (e.g. class B unregistered, class D cache)
|
# drifts not tied to a registered row (e.g. class B unregistered, class D cache)
|
||||||
known = {s.get('name') for s in sessions}
|
known = {s.get('name') for s in sessions}
|
||||||
extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known]
|
extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known]
|
||||||
@@ -213,5 +213,5 @@ if extra:
|
|||||||
for dr in extra:
|
for dr in extra:
|
||||||
print(f" [{dr['class']}] {dr['msg']}")
|
print(f" [{dr['class']}] {dr['msg']}")
|
||||||
print("=" * 136)
|
print("=" * 136)
|
||||||
print(f"alive tmux: {sorted(alive)}")
|
print(f"alive herdr: {sorted(alive)}")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|||||||
@@ -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,22 @@ 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 (real native command — `herdr has-session` is a lib.sh
|
||||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux gone"
|
# tmux-compat pseudo-command and needs `source .agents/skills/lib.sh` first)
|
||||||
|
herdr agent get "$SESSION_NAME" >/dev/null 2>&1 && 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 +132,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** → there's no `herdr detach` CLI command; press the herdr detach keybinding inside the pane, 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 agent send <target> <text>` (real native command; `herdr send-keys` is a lib.sh tmux-compat pseudo-command that needs `source .agents/skills/lib.sh` first). 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
|
||||||
@@ -67,11 +67,23 @@ while [ $# -gt 0 ]; do
|
|||||||
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
if [ -n "$AGENT" ]; then
|
||||||
|
case "$AGENT" in
|
||||||
|
claude|agy|hermes|cline) ;;
|
||||||
|
*) echo "ERROR: invalid agent type '$AGENT'. Allowed types are: claude, agy, hermes, cline." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
[ -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")"
|
# Implement the purging-<session> file lock mechanism
|
||||||
export TMUX_SERVER_NAME
|
if [ "$PURGE" = "1" ]; then
|
||||||
|
touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"
|
||||||
|
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_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 +102,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 +143,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 +169,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 +180,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,27 +195,27 @@ 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 (tmux attach -t '$SESSION_NAME'), then re-run" >&2
|
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
|
||||||
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
|
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
|
||||||
delegate_publish_event "$DELEGATE_JOB_ID" error "purge aborted: tmux session still alive"
|
delegate_publish_event "$DELEGATE_JOB_ID" error "purge aborted: herdr session still alive"
|
||||||
exit 4
|
exit 4
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -226,7 +238,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 +349,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:
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class _Header extends StatelessWidget {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_Pill(text: s.status.toUpperCase()),
|
_Pill(text: s.status.toUpperCase()),
|
||||||
_Pill(text: s.tmuxAlive ? 'TMUX ALIVE' : 'TMUX DEAD'),
|
_Pill(text: s.herdrAlive ? 'HERDR ALIVE' : 'HERDR DEAD'),
|
||||||
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
|
if (s.role != null) _Pill(text: s.role!.toUpperCase()),
|
||||||
_Pill(text: 'SERVER: ${s.server}'),
|
_Pill(text: 'SERVER: ${s.server}'),
|
||||||
],
|
],
|
||||||
|
|||||||
+4
-4
@@ -47,7 +47,7 @@ class SessionTable extends StatelessWidget {
|
|||||||
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
|
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
|
||||||
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
|
DataColumn2(label: Text('SERVER'), size: ColumnSize.S),
|
||||||
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
|
DataColumn2(label: Text('YAML'), size: ColumnSize.S),
|
||||||
DataColumn2(label: Text('TMUX'), size: ColumnSize.S),
|
DataColumn2(label: Text('HERDR'), size: ColumnSize.S),
|
||||||
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
|
DataColumn2(label: Text('CMD'), size: ColumnSize.S),
|
||||||
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
|
DataColumn2(label: Text('RESUME'), size: ColumnSize.S),
|
||||||
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
|
DataColumn2(label: Text('JOB_ID'), size: ColumnSize.S),
|
||||||
@@ -79,7 +79,7 @@ class SessionTable extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataCell(_StatusChip(status: s.status)),
|
DataCell(_StatusChip(status: s.status)),
|
||||||
DataCell(_TmuxChip(alive: s.tmuxAlive)),
|
DataCell(_HerdrChip(alive: s.herdrAlive)),
|
||||||
DataCell(
|
DataCell(
|
||||||
Text(
|
Text(
|
||||||
s.pane.cmd ?? '?',
|
s.pane.cmd ?? '?',
|
||||||
@@ -150,9 +150,9 @@ class _StatusChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TmuxChip extends StatelessWidget {
|
class _HerdrChip extends StatelessWidget {
|
||||||
final bool alive;
|
final bool alive;
|
||||||
const _TmuxChip({required this.alive});
|
const _HerdrChip({required this.alive});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
+2
-2
@@ -40,8 +40,8 @@ class _TerminalPaneState extends State<TerminalPane> {
|
|||||||
Future<void> _startPty() async {
|
Future<void> _startPty() async {
|
||||||
try {
|
try {
|
||||||
final pty = await PtySession.start(
|
final pty = await PtySession.start(
|
||||||
'tmux',
|
'herdr',
|
||||||
['-L', widget.serverName, 'attach', '-t', widget.sessionName],
|
['-L', widget.serverName, 'session', 'attach', widget.sessionName],
|
||||||
);
|
);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
pty.close();
|
pty.close();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ void main() {
|
|||||||
name: 'demo-session',
|
name: 'demo-session',
|
||||||
server: 'default',
|
server: 'default',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
tmuxAlive: true,
|
herdrAlive: true,
|
||||||
pane: const Pane(
|
pane: const Pane(
|
||||||
pid: 123,
|
pid: 123,
|
||||||
cwd: '/x',
|
cwd: '/x',
|
||||||
@@ -26,7 +26,7 @@ void main() {
|
|||||||
resumeState: 'yes',
|
resumeState: 'yes',
|
||||||
jobId: '-',
|
jobId: '-',
|
||||||
jobStatus: '-',
|
jobStatus: '-',
|
||||||
attachCommand: 'tmux attach -t demo-session',
|
attachCommand: 'herdr session attach demo-session',
|
||||||
driftClasses: const ['B'],
|
driftClasses: const ['B'],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
{
|
|
||||||
"configVersion": 2,
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/async-2.13.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/crypto-3.0.7",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ffi",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/ffi-2.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_pty",
|
|
||||||
"rootUri": "../../../packages/mam_pty",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/meta-1.19.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/path-1.9.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf-1.4.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_web_socket-1.0.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.17"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web-0.5.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web_socket_channel-2.4.5",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_pty_bridge_daemon",
|
|
||||||
"rootUri": "../",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generator": "pub",
|
|
||||||
"generatorVersion": "3.12.1",
|
|
||||||
"flutterRoot": "file:///home/godopu16/puenv/flutter",
|
|
||||||
"flutterVersion": "3.44.1",
|
|
||||||
"pubCache": "file:///home/godopu16/.pub-cache"
|
|
||||||
}
|
|
||||||
-150
@@ -1,150 +0,0 @@
|
|||||||
{
|
|
||||||
"roots": [
|
|
||||||
"mam_pty_bridge_daemon"
|
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "mam_pty_bridge_daemon",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"mam_pty",
|
|
||||||
"meta"
|
|
||||||
],
|
|
||||||
"devDependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_pty",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"ffi",
|
|
||||||
"meta",
|
|
||||||
"shelf",
|
|
||||||
"shelf_web_socket"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"version": "1.0.4",
|
|
||||||
"dependencies": [
|
|
||||||
"shelf",
|
|
||||||
"stream_channel",
|
|
||||||
"web_socket_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"version": "1.4.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"collection",
|
|
||||||
"http_parser",
|
|
||||||
"path",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"version": "4.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner",
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"version": "1.19.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"version": "1.4.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"version": "2.1.4",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"version": "1.12.1",
|
|
||||||
"dependencies": [
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"version": "1.9.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ffi",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"version": "1.10.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"path",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"version": "2.4.5",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"crypto",
|
|
||||||
"stream_channel",
|
|
||||||
"web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"version": "0.5.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"version": "3.0.7",
|
|
||||||
"dependencies": [
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"version": "2.13.1",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.19.0",
|
|
||||||
"dependencies": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configVersion": 1
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
{
|
|
||||||
"configVersion": 2,
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/async-2.13.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http-1.6.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_core",
|
|
||||||
"rootUri": "../../../packages/mam_core",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/meta-1.19.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/path-1.9.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web-1.1.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/yaml-3.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_tui",
|
|
||||||
"rootUri": "../",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generator": "pub",
|
|
||||||
"generatorVersion": "3.12.1",
|
|
||||||
"flutterRoot": "file:///home/godopu16/puenv/flutter",
|
|
||||||
"flutterVersion": "3.44.1",
|
|
||||||
"pubCache": "file:///home/godopu16/.pub-cache"
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
{
|
|
||||||
"roots": [
|
|
||||||
"mam_tui"
|
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "mam_tui",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"mam_core",
|
|
||||||
"meta"
|
|
||||||
],
|
|
||||||
"devDependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_core",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"http",
|
|
||||||
"meta",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"version": "3.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"version": "1.10.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"path",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"version": "1.9.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"version": "1.19.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http",
|
|
||||||
"version": "1.6.0",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"http_parser",
|
|
||||||
"meta",
|
|
||||||
"web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"version": "1.1.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"version": "4.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner",
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"version": "1.4.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"version": "2.13.1",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.19.0",
|
|
||||||
"dependencies": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configVersion": 1
|
|
||||||
}
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
{
|
|
||||||
"configVersion": 2,
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "_fe_analyzer_shared",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-105.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "analyzer",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/analyzer-14.1.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "args",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/args-2.7.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/async-2.13.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cli_config",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/cli_config-0.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "convert",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/convert-3.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "coverage",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/coverage-1.15.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/crypto-3.0.7",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/file-7.0.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontend_server_client",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/frontend_server_client-4.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "glob",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/glob-2.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http-1.6.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_multi_server",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "io",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/io-1.0.5",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "logging",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/logging-1.3.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/matcher-0.12.20",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/meta-1.19.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mime",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/mime-2.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "node_preamble",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/node_preamble-2.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.12"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "package_config",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/package_config-3.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/path-1.9.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pool",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/pool-1.5.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pub_semver",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf-1.4.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_packages_handler",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_packages_handler-3.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.17"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_static",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_static-1.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_web_socket-3.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_map_stack_trace",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_map_stack_trace-2.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_maps",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_maps-0.10.13",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test-1.31.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test_api-0.7.13",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_core",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test_core-0.6.19",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/vm_service-15.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "watcher",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/watcher-1.2.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web-1.1.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web_socket-1.0.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web_socket_channel-3.0.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "webkit_inspection_protocol",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/webkit_inspection_protocol-1.2.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/yaml-3.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_core",
|
|
||||||
"rootUri": "../",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generator": "pub",
|
|
||||||
"generatorVersion": "3.12.1",
|
|
||||||
"flutterRoot": "file:///home/godopu16/puenv/flutter",
|
|
||||||
"flutterVersion": "3.44.1",
|
|
||||||
"pubCache": "file:///home/godopu16/.pub-cache"
|
|
||||||
}
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
{
|
|
||||||
"roots": [
|
|
||||||
"mam_core"
|
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "mam_core",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"http",
|
|
||||||
"meta",
|
|
||||||
"yaml"
|
|
||||||
],
|
|
||||||
"devDependencies": [
|
|
||||||
"test"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"version": "1.31.2",
|
|
||||||
"dependencies": [
|
|
||||||
"analyzer",
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"coverage",
|
|
||||||
"http_multi_server",
|
|
||||||
"io",
|
|
||||||
"matcher",
|
|
||||||
"node_preamble",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pool",
|
|
||||||
"shelf",
|
|
||||||
"shelf_packages_handler",
|
|
||||||
"shelf_static",
|
|
||||||
"shelf_web_socket",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"test_api",
|
|
||||||
"test_core",
|
|
||||||
"typed_data",
|
|
||||||
"web_socket_channel",
|
|
||||||
"webkit_inspection_protocol",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.19.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http",
|
|
||||||
"version": "1.6.0",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"http_parser",
|
|
||||||
"meta",
|
|
||||||
"web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"version": "3.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "webkit_inspection_protocol",
|
|
||||||
"version": "1.2.1",
|
|
||||||
"dependencies": [
|
|
||||||
"logging"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"version": "3.0.3",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"crypto",
|
|
||||||
"stream_channel",
|
|
||||||
"web",
|
|
||||||
"web_socket"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"version": "1.4.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_core",
|
|
||||||
"version": "0.6.19",
|
|
||||||
"dependencies": [
|
|
||||||
"analyzer",
|
|
||||||
"args",
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"coverage",
|
|
||||||
"frontend_server_client",
|
|
||||||
"glob",
|
|
||||||
"io",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pool",
|
|
||||||
"source_map_stack_trace",
|
|
||||||
"source_maps",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"test_api",
|
|
||||||
"vm_service",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"version": "0.7.13",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"meta",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"string_scanner",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"version": "2.1.4",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"version": "1.12.1",
|
|
||||||
"dependencies": [
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"version": "1.10.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"path",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"version": "3.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"shelf",
|
|
||||||
"stream_channel",
|
|
||||||
"web_socket_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_static",
|
|
||||||
"version": "1.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"convert",
|
|
||||||
"http_parser",
|
|
||||||
"mime",
|
|
||||||
"path",
|
|
||||||
"shelf"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_packages_handler",
|
|
||||||
"version": "3.0.2",
|
|
||||||
"dependencies": [
|
|
||||||
"path",
|
|
||||||
"shelf",
|
|
||||||
"shelf_static"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"version": "1.4.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"collection",
|
|
||||||
"http_parser",
|
|
||||||
"path",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pool",
|
|
||||||
"version": "1.5.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"stack_trace"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"version": "1.9.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "package_config",
|
|
||||||
"version": "3.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "node_preamble",
|
|
||||||
"version": "2.0.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"version": "0.12.20",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"meta",
|
|
||||||
"stack_trace",
|
|
||||||
"term_glyph",
|
|
||||||
"test_api"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "io",
|
|
||||||
"version": "1.0.5",
|
|
||||||
"dependencies": [
|
|
||||||
"meta",
|
|
||||||
"path",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_multi_server",
|
|
||||||
"version": "3.2.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "coverage",
|
|
||||||
"version": "1.15.1",
|
|
||||||
"dependencies": [
|
|
||||||
"args",
|
|
||||||
"cli_config",
|
|
||||||
"glob",
|
|
||||||
"logging",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"source_maps",
|
|
||||||
"stack_trace",
|
|
||||||
"vm_service",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"version": "1.19.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"version": "2.13.1",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "analyzer",
|
|
||||||
"version": "14.1.0",
|
|
||||||
"dependencies": [
|
|
||||||
"_fe_analyzer_shared",
|
|
||||||
"collection",
|
|
||||||
"convert",
|
|
||||||
"crypto",
|
|
||||||
"glob",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pub_semver",
|
|
||||||
"source_span",
|
|
||||||
"watcher",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"version": "1.1.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"version": "4.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner",
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "logging",
|
|
||||||
"version": "1.3.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket",
|
|
||||||
"version": "1.0.1",
|
|
||||||
"dependencies": [
|
|
||||||
"web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"version": "3.0.7",
|
|
||||||
"dependencies": [
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"version": "15.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_maps",
|
|
||||||
"version": "0.10.13",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_map_stack_trace",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"path",
|
|
||||||
"source_maps",
|
|
||||||
"stack_trace"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "glob",
|
|
||||||
"version": "2.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"collection",
|
|
||||||
"file",
|
|
||||||
"path",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontend_server_client",
|
|
||||||
"version": "4.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "args",
|
|
||||||
"version": "2.7.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mime",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "convert",
|
|
||||||
"version": "3.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cli_config",
|
|
||||||
"version": "0.2.0",
|
|
||||||
"dependencies": [
|
|
||||||
"args",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "watcher",
|
|
||||||
"version": "1.2.1",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pub_semver",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "_fe_analyzer_shared",
|
|
||||||
"version": "105.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"version": "7.0.1",
|
|
||||||
"dependencies": [
|
|
||||||
"meta",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configVersion": 1
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
/// The tmux pane backing a session row, as reported by `sessions_detail`.
|
/// The herdr pane backing a session row, as reported by `sessions_detail`.
|
||||||
@immutable
|
@immutable
|
||||||
class Pane {
|
class Pane {
|
||||||
final int? pid;
|
final int? pid;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class SessionRow {
|
|||||||
final String name;
|
final String name;
|
||||||
final String server;
|
final String server;
|
||||||
final String status;
|
final String status;
|
||||||
final bool tmuxAlive;
|
final bool herdrAlive;
|
||||||
final Pane pane;
|
final Pane pane;
|
||||||
final String? role;
|
final String? role;
|
||||||
final String resumeState;
|
final String resumeState;
|
||||||
@@ -28,7 +28,7 @@ class SessionRow {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.server,
|
required this.server,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.tmuxAlive,
|
required this.herdrAlive,
|
||||||
required this.pane,
|
required this.pane,
|
||||||
this.role,
|
this.role,
|
||||||
required this.resumeState,
|
required this.resumeState,
|
||||||
@@ -49,7 +49,7 @@ class SessionRow {
|
|||||||
name: json['name'] as String? ?? '?',
|
name: json['name'] as String? ?? '?',
|
||||||
server: json['server'] as String? ?? 'default',
|
server: json['server'] as String? ?? 'default',
|
||||||
status: json['status'] as String? ?? '?',
|
status: json['status'] as String? ?? '?',
|
||||||
tmuxAlive: json['tmux_alive'] as bool? ?? false,
|
herdrAlive: json['herdr_alive'] as bool? ?? false,
|
||||||
pane: Pane.fromSessionJson(json),
|
pane: Pane.fromSessionJson(json),
|
||||||
role: json['role'] as String?,
|
role: json['role'] as String?,
|
||||||
resumeState: json['resume_state'] as String? ?? '?',
|
resumeState: json['resume_state'] as String? ?? '?',
|
||||||
@@ -64,3 +64,4 @@ class SessionRow {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-8
@@ -4,14 +4,14 @@ import 'drift_entry.dart';
|
|||||||
import 'session_row.dart';
|
import 'session_row.dart';
|
||||||
|
|
||||||
/// The full parsed result of one `status.sh --json` call: the untouched
|
/// The full parsed result of one `status.sh --json` call: the untouched
|
||||||
/// pre-D8 keys (timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/
|
/// pre-D8 keys (timestamp/yaml_path/herdr_sessions_alive/herdr_confirmed/
|
||||||
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
|
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
|
||||||
@immutable
|
@immutable
|
||||||
class SessionsSnapshot {
|
class SessionsSnapshot {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final String yamlPath;
|
final String yamlPath;
|
||||||
final List<String> tmuxSessionsAlive;
|
final List<String> herdrSessionsAlive;
|
||||||
final bool tmuxConfirmed;
|
final bool herdrConfirmed;
|
||||||
final List<DriftEntry> drifts;
|
final List<DriftEntry> drifts;
|
||||||
final List<String> actions;
|
final List<String> actions;
|
||||||
final List<SessionRow> sessions;
|
final List<SessionRow> sessions;
|
||||||
@@ -19,8 +19,8 @@ class SessionsSnapshot {
|
|||||||
const SessionsSnapshot({
|
const SessionsSnapshot({
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
required this.yamlPath,
|
required this.yamlPath,
|
||||||
required this.tmuxSessionsAlive,
|
required this.herdrSessionsAlive,
|
||||||
required this.tmuxConfirmed,
|
required this.herdrConfirmed,
|
||||||
required this.drifts,
|
required this.drifts,
|
||||||
required this.actions,
|
required this.actions,
|
||||||
required this.sessions,
|
required this.sessions,
|
||||||
@@ -32,11 +32,11 @@ class SessionsSnapshot {
|
|||||||
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
|
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
|
||||||
DateTime.now().toUtc(),
|
DateTime.now().toUtc(),
|
||||||
yamlPath: json['yaml_path'] as String? ?? '',
|
yamlPath: json['yaml_path'] as String? ?? '',
|
||||||
tmuxSessionsAlive:
|
herdrSessionsAlive:
|
||||||
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
|
(json['herdr_sessions_alive'] as List<dynamic>? ?? const [])
|
||||||
.map((e) => e.toString())
|
.map((e) => e.toString())
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
|
herdrConfirmed: json['herdr_confirmed'] as bool? ?? false,
|
||||||
drifts: (json['drifts'] as List<dynamic>? ?? const [])
|
drifts: (json['drifts'] as List<dynamic>? ?? const [])
|
||||||
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
|
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
@@ -49,3 +49,4 @@ class SessionsSnapshot {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -37,9 +37,9 @@ class SessionService {
|
|||||||
throw StatusFetchException('preflight failed: bash is missing or not executable');
|
throw StatusFetchException('preflight failed: bash is missing or not executable');
|
||||||
}
|
}
|
||||||
|
|
||||||
final tmuxResult = await runCommand(['tmux', '-V'], timeout: timeout);
|
final herdrResult = await runCommand(['herdr', '-V'], timeout: timeout);
|
||||||
if (tmuxResult.rc != 0) {
|
if (herdrResult.rc != 0) {
|
||||||
throw StatusFetchException('preflight failed: tmux is missing or not executable');
|
throw StatusFetchException('preflight failed: herdr is missing or not executable');
|
||||||
}
|
}
|
||||||
|
|
||||||
_preflightChecked = true;
|
_preflightChecked = true;
|
||||||
|
|||||||
+11
-10
@@ -24,18 +24,18 @@ void main() {
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-07-16T11:56:54Z",
|
"timestamp": "2026-07-16T11:56:54Z",
|
||||||
"yaml_path": "/x/.mam/agent-sessions.yaml",
|
"yaml_path": "/x/.mam/agent-sessions.yaml",
|
||||||
"tmux_sessions_alive": ["a|default"],
|
"herdr_sessions_alive": ["a|default"],
|
||||||
"tmux_confirmed": true,
|
"herdr_confirmed": true,
|
||||||
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
|
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
|
||||||
"actions": ["registered: a"],
|
"actions": ["registered: a"],
|
||||||
"sessions_detail": [
|
"sessions_detail": [
|
||||||
{
|
{
|
||||||
"name": "a", "server": "default", "status": "running",
|
"name": "a", "server": "default", "status": "running",
|
||||||
"tmux_alive": true, "cmd": "claude", "role": "creator",
|
"herdr_alive": true, "cmd": "claude", "role": "creator",
|
||||||
"resume_state": "yes", "job_id": "-", "job_status": "-",
|
"resume_state": "yes", "job_id": "-", "job_status": "-",
|
||||||
"pane_cwd": "/x", "attach_command": "tmux attach -t a",
|
"pane_cwd": "/x", "attach_command": "herdr session attach a",
|
||||||
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
|
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
|
||||||
"start_command": "tmux new-session ...", "last_visible_status": "running"
|
"start_command": "herdr agent start ...", "last_visible_status": "running"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ void main() {
|
|||||||
|
|
||||||
final snapshot = SessionsSnapshot.fromJson(json);
|
final snapshot = SessionsSnapshot.fromJson(json);
|
||||||
|
|
||||||
expect(snapshot.tmuxConfirmed, isTrue);
|
expect(snapshot.herdrConfirmed, isTrue);
|
||||||
expect(snapshot.drifts, hasLength(1));
|
expect(snapshot.drifts, hasLength(1));
|
||||||
expect(snapshot.drifts.single.driftClass, 'B');
|
expect(snapshot.drifts.single.driftClass, 'B');
|
||||||
expect(snapshot.sessions, hasLength(1));
|
expect(snapshot.sessions, hasLength(1));
|
||||||
@@ -56,7 +56,7 @@ void main() {
|
|||||||
expect(row.isTerminal, isFalse);
|
expect(row.isTerminal, isFalse);
|
||||||
expect(row.pane.pid, 123);
|
expect(row.pane.pid, 123);
|
||||||
expect(row.pane.cmdFull, 'claude --foo');
|
expect(row.pane.cmdFull, 'claude --foo');
|
||||||
expect(row.attachCommand, 'tmux attach -t a');
|
expect(row.attachCommand, 'herdr session attach a');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
|
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
|
||||||
@@ -64,7 +64,7 @@ void main() {
|
|||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
final snapshot = SessionsSnapshot.fromJson(json);
|
final snapshot = SessionsSnapshot.fromJson(json);
|
||||||
|
|
||||||
expect(snapshot.tmuxConfirmed, isFalse);
|
expect(snapshot.herdrConfirmed, isFalse);
|
||||||
expect(snapshot.sessions.single.name, 'bare');
|
expect(snapshot.sessions.single.name, 'bare');
|
||||||
expect(snapshot.sessions.single.resumeState, '?');
|
expect(snapshot.sessions.single.resumeState, '?');
|
||||||
expect(snapshot.sessions.single.pane.pid, isNull);
|
expect(snapshot.sessions.single.pane.pid, isNull);
|
||||||
@@ -85,8 +85,8 @@ void main() {
|
|||||||
final snapshot = await service.fetchSnapshot();
|
final snapshot = await service.fetchSnapshot();
|
||||||
|
|
||||||
expect(snapshot.yamlPath, isNotEmpty);
|
expect(snapshot.yamlPath, isNotEmpty);
|
||||||
// sessions_detail row count must match tmux_sessions_alive's distinct names.
|
// sessions_detail row count must match herdr_sessions_alive's distinct names.
|
||||||
final aliveNames = snapshot.tmuxSessionsAlive
|
final aliveNames = snapshot.herdrSessionsAlive
|
||||||
.map((entry) => entry.split('|').first)
|
.map((entry) => entry.split('|').first)
|
||||||
.toSet();
|
.toSet();
|
||||||
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
|
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
|
||||||
@@ -95,3 +95,4 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(seconds: 15)),
|
timeout: const Timeout(Duration(seconds: 15)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
{
|
|
||||||
"configVersion": 2,
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "_fe_analyzer_shared",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-105.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "analyzer",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/analyzer-14.1.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "args",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/args-2.7.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/async-2.13.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cli_config",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/cli_config-0.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/collection-1.19.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "convert",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/convert-3.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "coverage",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/coverage-1.15.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/crypto-3.0.7",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ffi",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/ffi-2.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/file-7.0.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontend_server_client",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/frontend_server_client-4.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "glob",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/glob-2.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_multi_server",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "io",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/io-1.0.5",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "logging",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/logging-1.3.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/matcher-0.12.20",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/meta-1.19.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mime",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/mime-2.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "node_preamble",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/node_preamble-2.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.12"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "package_config",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/package_config-3.0.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/path-1.9.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pool",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/pool-1.5.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pub_semver",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf-1.4.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_packages_handler",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_packages_handler-3.0.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.17"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_static",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_static-1.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/shelf_web_socket-1.0.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "2.17"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_map_stack_trace",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_map_stack_trace-2.1.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_maps",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_maps-0.10.13",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/source_span-1.10.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test-1.31.2",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test_api-0.7.13",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_core",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/test_core-0.6.19",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.11"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/vm_service-15.2.0",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "watcher",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/watcher-1.2.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web-0.5.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/web_socket_channel-2.4.5",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "webkit_inspection_protocol",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/webkit_inspection_protocol-1.2.1",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"rootUri": "file:///home/godopu16/.pub-cache/hosted/pub.dev/yaml-3.1.3",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mam_pty",
|
|
||||||
"rootUri": "../",
|
|
||||||
"packageUri": "lib/",
|
|
||||||
"languageVersion": "3.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"generator": "pub",
|
|
||||||
"generatorVersion": "3.12.1",
|
|
||||||
"flutterRoot": "file:///home/godopu16/puenv/flutter",
|
|
||||||
"flutterVersion": "3.44.1",
|
|
||||||
"pubCache": "file:///home/godopu16/.pub-cache"
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
{
|
|
||||||
"roots": [
|
|
||||||
"mam_pty"
|
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"name": "mam_pty",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"ffi",
|
|
||||||
"meta",
|
|
||||||
"shelf",
|
|
||||||
"shelf_web_socket"
|
|
||||||
],
|
|
||||||
"devDependencies": [
|
|
||||||
"test"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"version": "1.31.2",
|
|
||||||
"dependencies": [
|
|
||||||
"analyzer",
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"coverage",
|
|
||||||
"http_multi_server",
|
|
||||||
"io",
|
|
||||||
"matcher",
|
|
||||||
"node_preamble",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pool",
|
|
||||||
"shelf",
|
|
||||||
"shelf_packages_handler",
|
|
||||||
"shelf_static",
|
|
||||||
"shelf_web_socket",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"test_api",
|
|
||||||
"test_core",
|
|
||||||
"typed_data",
|
|
||||||
"web_socket_channel",
|
|
||||||
"webkit_inspection_protocol",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "meta",
|
|
||||||
"version": "1.19.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_web_socket",
|
|
||||||
"version": "1.0.4",
|
|
||||||
"dependencies": [
|
|
||||||
"shelf",
|
|
||||||
"stream_channel",
|
|
||||||
"web_socket_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf",
|
|
||||||
"version": "1.4.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"collection",
|
|
||||||
"http_parser",
|
|
||||||
"path",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ffi",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "yaml",
|
|
||||||
"version": "3.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "webkit_inspection_protocol",
|
|
||||||
"version": "1.2.1",
|
|
||||||
"dependencies": [
|
|
||||||
"logging"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web_socket_channel",
|
|
||||||
"version": "2.4.5",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"crypto",
|
|
||||||
"stream_channel",
|
|
||||||
"web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "typed_data",
|
|
||||||
"version": "1.4.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_core",
|
|
||||||
"version": "0.6.19",
|
|
||||||
"dependencies": [
|
|
||||||
"analyzer",
|
|
||||||
"args",
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"coverage",
|
|
||||||
"frontend_server_client",
|
|
||||||
"glob",
|
|
||||||
"io",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pool",
|
|
||||||
"source_map_stack_trace",
|
|
||||||
"source_maps",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"test_api",
|
|
||||||
"vm_service",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test_api",
|
|
||||||
"version": "0.7.13",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"boolean_selector",
|
|
||||||
"collection",
|
|
||||||
"meta",
|
|
||||||
"source_span",
|
|
||||||
"stack_trace",
|
|
||||||
"stream_channel",
|
|
||||||
"string_scanner",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stream_channel",
|
|
||||||
"version": "2.1.4",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stack_trace",
|
|
||||||
"version": "1.12.1",
|
|
||||||
"dependencies": [
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_span",
|
|
||||||
"version": "1.10.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"path",
|
|
||||||
"term_glyph"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_static",
|
|
||||||
"version": "1.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"convert",
|
|
||||||
"http_parser",
|
|
||||||
"mime",
|
|
||||||
"path",
|
|
||||||
"shelf"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "shelf_packages_handler",
|
|
||||||
"version": "3.0.2",
|
|
||||||
"dependencies": [
|
|
||||||
"path",
|
|
||||||
"shelf",
|
|
||||||
"shelf_static"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pool",
|
|
||||||
"version": "1.5.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"stack_trace"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "path",
|
|
||||||
"version": "1.9.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "package_config",
|
|
||||||
"version": "3.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "node_preamble",
|
|
||||||
"version": "2.0.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "matcher",
|
|
||||||
"version": "0.12.20",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"meta",
|
|
||||||
"stack_trace",
|
|
||||||
"term_glyph",
|
|
||||||
"test_api"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "io",
|
|
||||||
"version": "1.0.5",
|
|
||||||
"dependencies": [
|
|
||||||
"meta",
|
|
||||||
"path",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_multi_server",
|
|
||||||
"version": "3.2.2",
|
|
||||||
"dependencies": [
|
|
||||||
"async"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "coverage",
|
|
||||||
"version": "1.15.1",
|
|
||||||
"dependencies": [
|
|
||||||
"args",
|
|
||||||
"cli_config",
|
|
||||||
"glob",
|
|
||||||
"logging",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"source_maps",
|
|
||||||
"stack_trace",
|
|
||||||
"vm_service",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "collection",
|
|
||||||
"version": "1.19.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "boolean_selector",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "async",
|
|
||||||
"version": "2.13.1",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "analyzer",
|
|
||||||
"version": "14.1.0",
|
|
||||||
"dependencies": [
|
|
||||||
"_fe_analyzer_shared",
|
|
||||||
"collection",
|
|
||||||
"convert",
|
|
||||||
"crypto",
|
|
||||||
"glob",
|
|
||||||
"meta",
|
|
||||||
"package_config",
|
|
||||||
"path",
|
|
||||||
"pub_semver",
|
|
||||||
"source_span",
|
|
||||||
"watcher",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "http_parser",
|
|
||||||
"version": "4.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"collection",
|
|
||||||
"source_span",
|
|
||||||
"string_scanner",
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "string_scanner",
|
|
||||||
"version": "1.4.1",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "logging",
|
|
||||||
"version": "1.3.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "web",
|
|
||||||
"version": "0.5.1",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "crypto",
|
|
||||||
"version": "3.0.7",
|
|
||||||
"dependencies": [
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "vm_service",
|
|
||||||
"version": "15.2.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_maps",
|
|
||||||
"version": "0.10.13",
|
|
||||||
"dependencies": [
|
|
||||||
"source_span"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "source_map_stack_trace",
|
|
||||||
"version": "2.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"path",
|
|
||||||
"source_maps",
|
|
||||||
"stack_trace"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "glob",
|
|
||||||
"version": "2.1.3",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"collection",
|
|
||||||
"file",
|
|
||||||
"path",
|
|
||||||
"string_scanner"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontend_server_client",
|
|
||||||
"version": "4.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "args",
|
|
||||||
"version": "2.7.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "term_glyph",
|
|
||||||
"version": "1.2.2",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mime",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"dependencies": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "convert",
|
|
||||||
"version": "3.1.2",
|
|
||||||
"dependencies": [
|
|
||||||
"typed_data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cli_config",
|
|
||||||
"version": "0.2.0",
|
|
||||||
"dependencies": [
|
|
||||||
"args",
|
|
||||||
"yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "watcher",
|
|
||||||
"version": "1.2.1",
|
|
||||||
"dependencies": [
|
|
||||||
"async",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pub_semver",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"dependencies": [
|
|
||||||
"collection"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "_fe_analyzer_shared",
|
|
||||||
"version": "105.0.0",
|
|
||||||
"dependencies": [
|
|
||||||
"meta"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "file",
|
|
||||||
"version": "7.0.1",
|
|
||||||
"dependencies": [
|
|
||||||
"meta",
|
|
||||||
"path"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configVersion": 1
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -182,7 +182,9 @@ class PtySession {
|
|||||||
// A. Disassociate controlling terminal
|
// A. Disassociate controlling terminal
|
||||||
setsid();
|
setsid();
|
||||||
|
|
||||||
// B. Isolate from nested TMUX environments (§6.7)
|
// B. Isolate from nested TMUX/Herdr environments (§6.7)
|
||||||
|
// We must unset TMUX and TMUX_PANE to ensure the child shell is properly isolated
|
||||||
|
// from any parent terminal multiplexer session, preventing unexpected nested behavior.
|
||||||
unsetenv(tmuxNamePtr.cast<ffi.Char>());
|
unsetenv(tmuxNamePtr.cast<ffi.Char>());
|
||||||
unsetenv(tmuxPaneNamePtr.cast<ffi.Char>());
|
unsetenv(tmuxPaneNamePtr.cast<ffi.Char>());
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -20,4 +20,11 @@ __pycache__/
|
|||||||
|
|
||||||
# 빌드/배포 HTML 산출물
|
# 빌드/배포 HTML 산출물
|
||||||
.agents/skills/multi-agent-mux-delegate-job/USER_MANUAL.html
|
.agents/skills/multi-agent-mux-delegate-job/USER_MANUAL.html
|
||||||
.agents/skills/multi-agent-mux-delegate-job/mqtt-broker-setup.html
|
.agents/skills/multi-agent-mux-delegate-job/mqtt-broker-setup.html
|
||||||
|
|
||||||
|
# Flutter/Dart 빌드 산출물 및 IDE 파일
|
||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
ephemeral/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Test Infrastructure Specification
|
||||||
|
|
||||||
|
## Test Philosophy
|
||||||
|
We adopt an **opaque-box, requirement-driven** testing philosophy for the tmux-to-herdr migration scripts.
|
||||||
|
This approach ensures that the test suite validates external behaviors, input/output contracts, and side-effects rather than asserting internal code structure or layout. The scripts are treated as black boxes that:
|
||||||
|
- Accept CLI arguments and environment variables.
|
||||||
|
- Query/interact with the `herdr` daemon through the `_herdr` shim (using mock executable interception).
|
||||||
|
- Perform state mutations inside `.mam/agent-sessions.yaml` and `.mam/agent-sessions.db`.
|
||||||
|
- Interact with background agent runners (`claude`, `agy`, `hermes`, `cline`).
|
||||||
|
|
||||||
|
This guarantees that our test assertions remain stable even if the script implementation details are refactored, as long as the functional requirements are met.
|
||||||
|
|
||||||
|
## Feature Inventory
|
||||||
|
The test suite is structured around five core features, mapping out verification checks across Tiers 1, 2, and 3:
|
||||||
|
|
||||||
|
| Feature | Tier 1 (Unit Checks) | Tier 2 (Component Checks) | Tier 3 (Integration Checks) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Create Session** | - `derive_session_name` slug generation checks<br>- Workspace-to-slug character translation<br>- Invalid workspace path filtering<br>- Role parameter sanity validations<br>- Session override string generation | - Verification of state serialization to YAML schema<br>- Isolation home directory structure validation<br>- SQLite DB connection verification<br>- Concurrency check for database registration lock<br>- Database schema validation on write | - Spawn session execution with mock `herdr` and mock agent<br>- TUI readiness wait check<br>- Cleanup trap execution on crash<br>- Argument validation logic verification<br>- Isolation directory creation checks |
|
||||||
|
| **Resume Session** | - Workspace UUID resolution order unit tests<br>- CLI session ID parser validations<br>- Check prioritization (yaml file -> disk scan -> cache)<br>- Workspace path boundary check<br>- Empty UUID handling logic | - Configuration restore verification<br>- Environment overrides assertion<br>- Integrity check on retrieved SQLite metadata<br>- Validation of session ownership verification<br>- Config parsing for resume options | - Run `resume_session.sh` with mock agents<br>- Intercept agent command structure inside mock `herdr`<br>- Verify agent receives correct conversation UUID flag<br>- Invalid/missing UUID recovery path test<br>- Workspace resume CLI args verification |
|
||||||
|
| **Stop Session** | - Session name verification check<br>- Purge verification confirmations logic<br>- Command derivation format validation<br>- Timeout calculation helper tests<br>- Reason logging serializer test | - Safe folder path validation (shutil protection)<br>- Database status field mutation serialization<br>- Isolation folder cleanup check<br>- Lock file release checks on stop<br>- Concurrency handling of stop mutations | - Execute `stop_session.sh` with graceful key delivery (`/exit`)<br>- Fallback to forcible termination (`herdr kill-session`) check<br>- Fallback to PID termination (`kill -9`) verify<br>- Purge files verification on disk (`--purge-conversation`)<br>- CLI flag verification with yes/no confirmation |
|
||||||
|
| **Status Query** | - JSON converter unit tests<br>- Diff formatter text generators<br>- Output alignment tests<br>- Table grid column math verify<br>- CLI status argument parse tests | - Status read locks verification<br>- Parsing of drift status classifications<br>- Concurrency read protection test<br>- Registry YAML-to-JSON structural translation<br>- Verification of database read access checks | - Running `status.sh` with `--json`<br>- Verify console output match formatting rules<br>- Verify exit status codes on different states<br>- Integration test with `reconcile.sh` read-only diff emission<br>- Verify status command doesn't trigger side effects |
|
||||||
|
| **Monitor/Reconcile** | - Drift state classification unit tests<br>- Signature verification checks<br>- Subscription topic parsing tests<br>- MQTT message structure validator<br>- HMAC validation logic tests | - Concurrency lock checks (`.mam/monitor.lock`)<br>- Verify YAML and SQLite database reconciliation logic<br>- DB validation on drift updates<br>- HMAC signature signature verification<br>- SQLite journal mode fallback check (WAL vs DELETE) | - Execute `reconcile.sh` in single-pass mode (`--once`)<br>- MQTT subscription execution with mock messages<br>- Verify auto-termination of orphaned herdr sessions<br>- Verify auto-registration of untracked herdr sessions<br>- Lock contention handling testing |
|
||||||
|
|
||||||
|
## Test Architecture
|
||||||
|
The E2E testing framework is built using **pytest** and relies on two main pillars to ensure hermetic and reproducible test runs:
|
||||||
|
1. **Environment Sandboxing**:
|
||||||
|
All tests run inside a temporary, isolated directory structure provided by the pytest `tmp_path` fixture. The workspace environment is sandboxed by:
|
||||||
|
- Creating a temporary `.mam/` directory.
|
||||||
|
- Using the `monkeypatch` fixture to override `AGENT_SESSIONS_YAML` pointing to the sandboxed path.
|
||||||
|
- Overriding relevant environment variables (like `HOME`, `WORKSPACE_ROOT`, etc.) to prevent tests from modifying the developer's system state.
|
||||||
|
2. **Mock Binaries Interception**:
|
||||||
|
To prevent tests from interacting with external systems or relying on running daemons:
|
||||||
|
- A mock `herdr` script is dynamically generated and placed in a temporary bin folder, which is prepended to the system `PATH`. This mock binary reads/writes to a JSON file (`mock_herdr_state.json`) which acts as the control pane for tests to assert that `herdr` was called with correct arguments and return mocked outputs (session list, capture-pane output, exit codes).
|
||||||
|
- Mock agent binaries (`claude`, `agy`, `hermes`, `cline`) are also generated and prepended to `PATH`. They emulate successful login verification commands (e.g. `claude auth status`) and mock conversation UUID generation on disk.
|
||||||
|
|
||||||
|
## Real-World Application Scenarios (Tier 4)
|
||||||
|
We define five key E2E scenarios representing end-to-end user workflows:
|
||||||
|
1. **Standard Agent Session Lifecycle**: Spawning a new worker agent session via `create_session.sh`, verifying it is registered correctly in the YAML database, checking its status via `status.sh`, and then gracefully stopping it via `stop_session.sh`.
|
||||||
|
2. **Session Disconnect and Resume**: Creating a session, simulating a network disconnect/agent pane termination (updating herdr state), calling `resume_session.sh` to restore it using the workspace-scoped UUID, and asserting that the session returns to the active state in both herdr and the registry.
|
||||||
|
3. **Drift Detection and Auto-Reconciliation**: Artificially introducing drift (e.g. terminating a herdr session manually from the backend while keeping it registered in the YAML registry, or starting a herdr session outside the scripts), running `reconcile.sh --once`, and verifying that orphaned sessions are terminated and registry state is updated.
|
||||||
|
4. **Parallel Session Operations with flock Locking**: Simulating concurrent creation/stop script invocations to verify that SQLite flock transactions block lost update races, and that the registry data remains consistent.
|
||||||
|
5. **Multi-Agent Orchestrator Review Loop**: Running the orchestrator loop (`run_loop.sh`) where a worker agent and a reviewer agent are spawned, reviewer verdicts (`PASS` and `NOT PASS`) are processed, loops are iterated, and planner escalation is triggered on failure.
|
||||||
|
|
||||||
|
## Coverage Thresholds
|
||||||
|
To ensure the test suite is comprehensive, we define the following coverage thresholds:
|
||||||
|
- **Tier 1 (Unit Tests)**: Minimum >=5 unit tests per feature (total >=25 unit tests).
|
||||||
|
- **Tier 2 (Component Tests)**: Minimum >=5 component tests per feature (total >=25 component tests).
|
||||||
|
- **Tier 3 (Integration Tests)**: Pairwise combination testing covering CLI options and environment overrides for all features.
|
||||||
|
- **Tier 4 (E2E Scenarios)**: At least 5 full real-world scenario tests implemented and passing.
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
# Test Ready Report
|
||||||
|
|
||||||
|
## Test Runner
|
||||||
|
- **Command**: `.venv/bin/pytest tests/`
|
||||||
|
- **Expected**: All tests pass with exit code 0
|
||||||
|
|
||||||
|
## Coverage Summary
|
||||||
|
- **1. Feature Coverage (Tier 1)**: 29 tests
|
||||||
|
- **2. Boundary & Corner (Tier 2)**: 26 tests
|
||||||
|
- **3. Cross-Feature (Tier 3)**: 5 tests
|
||||||
|
- **4. Real-World Application (Tier 4)**: 5 tests
|
||||||
|
- **Sanity Checks**: 2 tests
|
||||||
|
- **Challenger/M2 Unit**: 7 tests
|
||||||
|
- **Total**: 74 tests
|
||||||
|
|
||||||
|
## Feature Checklist
|
||||||
|
|
||||||
|
### 1. Create Session
|
||||||
|
- **Tier 1 (Unit Checks)**
|
||||||
|
- [x] `derive_session_name` slug generation checks
|
||||||
|
- [x] Workspace-to-slug character translation
|
||||||
|
- [x] Invalid workspace path filtering
|
||||||
|
- [x] Role parameter sanity validations
|
||||||
|
- [x] Session override string generation
|
||||||
|
- **Tier 2 (Component Checks)**
|
||||||
|
- [x] Verification of state serialization to YAML schema
|
||||||
|
- [x] Isolation home directory structure validation
|
||||||
|
- [x] SQLite DB connection verification
|
||||||
|
- [x] Concurrency check for database registration lock
|
||||||
|
- [x] Database schema validation on write
|
||||||
|
- **Tier 3 (Integration Checks)**
|
||||||
|
- [x] Spawn session execution with mock `herdr` and mock agent
|
||||||
|
- [x] TUI readiness wait check
|
||||||
|
- [x] Cleanup trap execution on crash
|
||||||
|
- [x] Argument validation logic verification
|
||||||
|
- [x] Isolation directory creation checks
|
||||||
|
- **Tier 4 (Real-World Application Scenarios)**
|
||||||
|
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
|
||||||
|
- [x] Parallel Session Operations with flock Locking E2E test (Scenario 4)
|
||||||
|
|
||||||
|
### 2. Resume Session
|
||||||
|
- **Tier 1 (Unit Checks)**
|
||||||
|
- [x] Workspace UUID resolution order unit tests
|
||||||
|
- [x] CLI session ID parser validations
|
||||||
|
- [x] Check prioritization (yaml file -> disk scan -> cache)
|
||||||
|
- [x] Workspace path boundary check
|
||||||
|
- [x] Empty UUID handling logic
|
||||||
|
- **Tier 2 (Component Checks)**
|
||||||
|
- [x] Configuration restore verification
|
||||||
|
- [x] Environment overrides assertion
|
||||||
|
- [x] Integrity check on retrieved SQLite metadata
|
||||||
|
- [x] Validation of session ownership verification
|
||||||
|
- [x] Config parsing for resume options
|
||||||
|
- **Tier 3 (Integration Checks)**
|
||||||
|
- [x] Run `resume_session.sh` with mock agents
|
||||||
|
- [x] Intercept agent command structure inside mock `herdr`
|
||||||
|
- [x] Verify agent receives correct conversation UUID flag
|
||||||
|
- [x] Invalid/missing UUID recovery path test
|
||||||
|
- [x] Workspace resume CLI args verification
|
||||||
|
- **Tier 4 (Real-World Application Scenarios)**
|
||||||
|
- [x] Session Disconnect and Resume E2E test (Scenario 2)
|
||||||
|
|
||||||
|
### 3. Stop Session
|
||||||
|
- **Tier 1 (Unit Checks)**
|
||||||
|
- [x] Session name verification check
|
||||||
|
- [x] Purge verification confirmations logic
|
||||||
|
- [x] Command derivation format validation
|
||||||
|
- [x] Timeout calculation helper tests
|
||||||
|
- [x] Reason logging serializer test
|
||||||
|
- **Tier 2 (Component Checks)**
|
||||||
|
- [x] Safe folder path validation (shutil protection)
|
||||||
|
- [x] Database status field mutation serialization
|
||||||
|
- [x] Isolation folder cleanup check
|
||||||
|
- [x] Lock file release checks on stop
|
||||||
|
- [x] Concurrency handling of stop mutations
|
||||||
|
- **Tier 3 (Integration Checks)**
|
||||||
|
- [x] Execute `stop_session.sh` with graceful key delivery (`/exit`)
|
||||||
|
- [x] Fallback to forcible termination (`herdr kill-session`) check
|
||||||
|
- [x] Fallback to PID termination (`kill -9`) verify
|
||||||
|
- [x] Purge files verification on disk (`--purge-conversation`)
|
||||||
|
- [x] CLI flag verification with yes/no confirmation
|
||||||
|
- **Tier 4 (Real-World Application Scenarios)**
|
||||||
|
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
|
||||||
|
- [x] Parallel Session Operations with flock Locking E2E test (Scenario 4)
|
||||||
|
|
||||||
|
### 4. Status Query
|
||||||
|
- **Tier 1 (Unit Checks)**
|
||||||
|
- [x] JSON converter unit tests
|
||||||
|
- [x] Diff formatter text generators
|
||||||
|
- [x] Output alignment tests
|
||||||
|
- [x] Table grid column math verify
|
||||||
|
- [x] CLI status argument parse tests
|
||||||
|
- **Tier 2 (Component Checks)**
|
||||||
|
- [x] Status read locks verification
|
||||||
|
- [x] Parsing of drift status classifications
|
||||||
|
- [x] Concurrency read protection test
|
||||||
|
- [x] Registry YAML-to-JSON structural translation
|
||||||
|
- [x] Verification of database read access checks
|
||||||
|
- **Tier 3 (Integration Checks)**
|
||||||
|
- [x] Running `status.sh` with `--json`
|
||||||
|
- [x] Verify console output match formatting rules
|
||||||
|
- [x] Verify exit status codes on different states
|
||||||
|
- [x] Integration test with `reconcile.sh` read-only diff emission
|
||||||
|
- [x] Verify status command doesn't trigger side effects
|
||||||
|
- **Tier 4 (Real-World Application Scenarios)**
|
||||||
|
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
|
||||||
|
- [x] Drift Detection and Auto-Reconciliation E2E test (Scenario 3)
|
||||||
|
|
||||||
|
### 5. Monitor/Reconcile
|
||||||
|
- **Tier 1 (Unit Checks)**
|
||||||
|
- [x] Drift state classification unit tests
|
||||||
|
- [x] Signature verification checks
|
||||||
|
- [x] Subscription topic parsing tests
|
||||||
|
- [x] MQTT message structure validator
|
||||||
|
- [x] HMAC validation logic tests
|
||||||
|
- **Tier 2 (Component Checks)**
|
||||||
|
- [x] Concurrency lock checks (`.mam/monitor.lock`)
|
||||||
|
- [x] Verify YAML and SQLite database reconciliation logic
|
||||||
|
- [x] DB validation on drift updates
|
||||||
|
- [x] HMAC signature signature verification
|
||||||
|
- [x] SQLite journal mode fallback check (WAL vs DELETE)
|
||||||
|
- **Tier 3 (Integration Checks)**
|
||||||
|
- [x] Execute `reconcile.sh` in single-pass mode (`--once`)
|
||||||
|
- [x] MQTT subscription execution with mock messages
|
||||||
|
- [x] Verify auto-termination of orphaned herdr sessions
|
||||||
|
- [x] Verify auto-registration of untracked herdr sessions
|
||||||
|
- [x] Lock contention handling testing
|
||||||
|
- **Tier 4 (Real-World Application Scenarios)**
|
||||||
|
- [x] Drift Detection and Auto-Reconciliation E2E test (Scenario 3)
|
||||||
+6
-6
@@ -8,7 +8,7 @@ MAM은 단일 워크스페이스 상에서 복수의 에이전트(Claude, Cline,
|
|||||||
|
|
||||||
## 1. ⚙️ 사전 요구사항
|
## 1. ⚙️ 사전 요구사항
|
||||||
MAM 스킬 및 스크립트들은 호스트 시스템의 다음 도구들에 의존합니다. 설치 전에 확인해 주세요.
|
MAM 스킬 및 스크립트들은 호스트 시스템의 다음 도구들에 의존합니다. 설치 전에 확인해 주세요.
|
||||||
* **tmux**: 에이전트를 백그라운드 격리 Pane에서 구동하기 위한 프로세스 컨테이너
|
* **herdr**: 에이전트를 백그라운드 격리 Pane/Workspace에서 구동 및 관제하기 위한 프로세스 컨테이너
|
||||||
* **python3**: 세션 레지스트리(YAML/SQLite DB) 파싱 및 유효성 검사 (내장 `sqlite3` 모듈 필수)
|
* **python3**: 세션 레지스트리(YAML/SQLite DB) 파싱 및 유효성 검사 (내장 `sqlite3` 모듈 필수)
|
||||||
* **uuidgen**: 격리 세션 생성 시 고유의 UUID 할당
|
* **uuidgen**: 격리 세션 생성 시 고유의 UUID 할당
|
||||||
* **rsync**: 인스톨러(`deploy/install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
|
* **rsync**: 인스톨러(`deploy/install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
|
||||||
@@ -54,14 +54,14 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
|
|||||||
--role developer \
|
--role developer \
|
||||||
--session my-project-dev-claude \
|
--session my-project-dev-claude \
|
||||||
--isolate \
|
--isolate \
|
||||||
--tmux-server multi-agent-mux
|
--herdr-workspace multi-agent-mux
|
||||||
```
|
```
|
||||||
* `--isolate` 옵션을 주면 `.mam/agent_homes/<uuid>/` 하위에 로그인 및 설정은 유지하되 대화 내역은 격리되는 홈이 형성됩니다.
|
* `--isolate` 옵션을 주면 `.mam/agent_homes/<uuid>/` 하위에 로그인 및 설정은 유지하되 대화 내역은 격리되는 홈이 형성됩니다.
|
||||||
|
|
||||||
### 2) 세션 접속 (Attach)
|
### 2) 세션 접속 (Attach)
|
||||||
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다. (세션 생성 시 지정한 독립 격리 tmux 서버 소켓 `-L multi-agent-mux` 를 경유해 접속합니다.)
|
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
|
||||||
```bash
|
```bash
|
||||||
$ tmux -L multi-agent-mux attach -t my-project-dev-claude
|
$ herdr session attach my-project-dev-claude
|
||||||
```
|
```
|
||||||
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
||||||
|
|
||||||
@@ -81,8 +81,8 @@ $ [ -n "$UUID" ] || { echo "[ERROR] 매칭되는 활성 세션 이력이 없습
|
|||||||
|
|
||||||
# 2단계: 세션 재기동 (이전 대화 컨텍스트 복원 기동)
|
# 2단계: 세션 재기동 (이전 대화 컨텍스트 복원 기동)
|
||||||
# (주의: 만약 create 시 격리(--isolate) 세션으로 생성했다면, CLAUDE_CONFIG_DIR 환경변수를 YAML에 기록된 isolation.root 경로로 지정하여 띄워야 합니다. 상세 격리 복원 커맨드는 .agents/skills/multi-agent-mux-resume/SKILL.md 문서를 필독해 주세요.)
|
# (주의: 만약 create 시 격리(--isolate) 세션으로 생성했다면, CLAUDE_CONFIG_DIR 환경변수를 YAML에 기록된 isolation.root 경로로 지정하여 띄워야 합니다. 상세 격리 복원 커맨드는 .agents/skills/multi-agent-mux-resume/SKILL.md 문서를 필독해 주세요.)
|
||||||
$ tmux -L multi-agent-mux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
$ herdr run -d --name "$SESSION_NAME" --workspace "$WORKSPACE" -- \
|
||||||
"claude --dangerously-skip-permissions -r $UUID; echo 'CLAUDE EXIT'; bash"
|
"claude --dangerously-skip-permissions -r $UUID"
|
||||||
|
|
||||||
# 3단계: 레지스트리 세션 상태를 running 으로 동기화 갱신
|
# 3단계: 레지스트리 세션 상태를 running 으로 동기화 갱신
|
||||||
$ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
$ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ fi
|
|||||||
|
|
||||||
# 1. Dependency Checks
|
# 1. Dependency Checks
|
||||||
log_info "Verifying host dependencies..."
|
log_info "Verifying host dependencies..."
|
||||||
DEPS=(tmux python3 rsync uuidgen)
|
DEPS=(herdr python3 rsync uuidgen)
|
||||||
MISSING_DEPS=()
|
MISSING_DEPS=()
|
||||||
for dep in "${DEPS[@]}"; do
|
for dep in "${DEPS[@]}"; do
|
||||||
if ! command -v "$dep" &>/dev/null; then
|
if ! command -v "$dep" &>/dev/null; then
|
||||||
@@ -93,6 +93,10 @@ done
|
|||||||
|
|
||||||
if [ ${#MISSING_DEPS[@]} -ne 0 ]; then
|
if [ ${#MISSING_DEPS[@]} -ne 0 ]; then
|
||||||
log_error "Missing required dependencies: ${MISSING_DEPS[*]}"
|
log_error "Missing required dependencies: ${MISSING_DEPS[*]}"
|
||||||
|
if [[ " ${MISSING_DEPS[*]} " == *" herdr "* ]]; then
|
||||||
|
log_error "To install herdr, please refer to the official installation guide:"
|
||||||
|
log_error " curl -fsSL https://herdr.dev/install.sh | sh"
|
||||||
|
fi
|
||||||
log_error "Please install them before using MAM."
|
log_error "Please install them before using MAM."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mam_sandbox(tmp_path, monkeypatch):
|
||||||
|
"""
|
||||||
|
Manages a temporary sandboxed directory for .mam/ configuration files
|
||||||
|
and copies the scripts from .agents/skills to prevent contaminating the real codebase.
|
||||||
|
Also overrides environments (HOME, PATH, AGENT_SESSIONS_YAML, etc.) for isolation.
|
||||||
|
"""
|
||||||
|
# 1. Copy the skill scripts to sandboxed tmp_path/skills and tmp_path/.agents/skills
|
||||||
|
src_skills = "/home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills"
|
||||||
|
shutil.copytree(src_skills, tmp_path / "skills", ignore=shutil.ignore_patterns('multi-agent-mux-ui'))
|
||||||
|
shutil.copytree(src_skills, tmp_path / ".agents" / "skills", ignore=shutil.ignore_patterns('multi-agent-mux-ui'))
|
||||||
|
|
||||||
|
# 2. Create sandboxed directory structure
|
||||||
|
mam_dir = tmp_path / ".mam"
|
||||||
|
mam_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
yaml_path = mam_dir / "agent-sessions.yaml"
|
||||||
|
# Create empty registry structure
|
||||||
|
yaml_path.write_text("herdr_sessions: []\ndelegation_jobs: []\n")
|
||||||
|
|
||||||
|
# 3. Setup mock home directory contents
|
||||||
|
(tmp_path / ".claude" / "projects").mkdir(parents=True, exist_ok=True)
|
||||||
|
(tmp_path / ".local" / "bin").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 4. Monkeypatch environment variables to point inside the sandbox
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("HOME_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / ".claude" / "projects"))
|
||||||
|
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
|
||||||
|
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||||
|
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||||
|
import sys
|
||||||
|
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
|
||||||
|
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_herdr(mam_sandbox, monkeypatch):
|
||||||
|
"""
|
||||||
|
Generates a mock 'herdr' executable and prepends it to PATH.
|
||||||
|
Allows tests to control and verify state via mock_herdr_state.json.
|
||||||
|
Automatically generates mock session database files upon 'agent start'.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
state_file = tmp_path / "mock_herdr_state.json"
|
||||||
|
|
||||||
|
# Initial mock state
|
||||||
|
initial_state = {
|
||||||
|
"workspaces": [
|
||||||
|
{
|
||||||
|
"workspace_id": "w1",
|
||||||
|
"label": "default",
|
||||||
|
"cwd": str(tmp_path)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"agents": {},
|
||||||
|
"calls": []
|
||||||
|
}
|
||||||
|
state_file.write_text(json.dumps(initial_state, indent=2))
|
||||||
|
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
herdr_bin = bin_dir / "herdr"
|
||||||
|
|
||||||
|
# Write Python implementation of mock herdr (using string replacement to avoid f-string escaping issues)
|
||||||
|
# Double escape backslashes for newline (\\\\n) and write correctly
|
||||||
|
code = """#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import sqlite3
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
state_file = "STATE_FILE_PLACEHOLDER"
|
||||||
|
|
||||||
|
if not os.path.exists(state_file):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Lock the state file exclusively to prevent concurrent race conditions
|
||||||
|
lock_f = open(state_file + ".lock", "w")
|
||||||
|
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
||||||
|
|
||||||
|
with open(state_file, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
# Record the command call
|
||||||
|
state["calls"].append(sys.argv[1:])
|
||||||
|
|
||||||
|
def save_state():
|
||||||
|
with open(state_file, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
# Save calls immediately so they persist even if we exit early or error out
|
||||||
|
save_state()
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args and args[0] == "--session":
|
||||||
|
if len(args) > 1:
|
||||||
|
args = args[2:]
|
||||||
|
else:
|
||||||
|
args = args[1:]
|
||||||
|
if not args:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cmd1 = args[0]
|
||||||
|
if cmd1 == "workspace":
|
||||||
|
if len(args) < 2:
|
||||||
|
sys.exit(0)
|
||||||
|
cmd2 = args[1]
|
||||||
|
if cmd2 == "list":
|
||||||
|
res = {"workspaces": state.get("workspaces", [])}
|
||||||
|
print(json.dumps({"result": res}))
|
||||||
|
sys.exit(0)
|
||||||
|
elif cmd2 == "create":
|
||||||
|
label = "default"
|
||||||
|
cwd = "."
|
||||||
|
i = 2
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "--label":
|
||||||
|
label = args[i+1]
|
||||||
|
i += 2
|
||||||
|
elif args[i] == "--cwd":
|
||||||
|
cwd = args[i+1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
workspaces = state.get("workspaces", [])
|
||||||
|
if not any(w["label"] == label for w in workspaces):
|
||||||
|
ws_id = f"w{len(workspaces) + 1}"
|
||||||
|
workspaces.append({"workspace_id": ws_id, "label": label, "cwd": cwd})
|
||||||
|
state["workspaces"] = workspaces
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd1 == "agent":
|
||||||
|
if len(args) < 2:
|
||||||
|
sys.exit(0)
|
||||||
|
cmd2 = args[1]
|
||||||
|
if cmd2 == "list":
|
||||||
|
agents_list = []
|
||||||
|
for name, data in state.get("agents", {}).items():
|
||||||
|
agents_list.append({
|
||||||
|
"name": name,
|
||||||
|
"agent": data.get("agent", "claude"),
|
||||||
|
"agent_status": data.get("status", "running"),
|
||||||
|
"cwd": data.get("cwd", ""),
|
||||||
|
"pane_id": data.get("pane_id", "w1:p1"),
|
||||||
|
"workspace_id": data.get("workspace_id", "w1")
|
||||||
|
})
|
||||||
|
print(json.dumps({"result": {"agents": agents_list}}))
|
||||||
|
sys.exit(0)
|
||||||
|
elif cmd2 == "start":
|
||||||
|
if len(args) < 3:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
ws = ""
|
||||||
|
cwd = ""
|
||||||
|
# Find where -- is
|
||||||
|
try:
|
||||||
|
double_dash_idx = args.index("--")
|
||||||
|
agent_cmd = args[double_dash_idx+1:]
|
||||||
|
opts = args[3:double_dash_idx]
|
||||||
|
except ValueError:
|
||||||
|
agent_cmd = []
|
||||||
|
opts = args[3:]
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(opts):
|
||||||
|
if opts[i] == "--workspace":
|
||||||
|
ws = opts[i+1]
|
||||||
|
i += 2
|
||||||
|
elif opts[i] == "--cwd":
|
||||||
|
cwd = opts[i+1]
|
||||||
|
i += 2
|
||||||
|
elif opts[i] == "--env":
|
||||||
|
env_val = opts[i+1]
|
||||||
|
if "=" in env_val:
|
||||||
|
k, v = env_val.split("=", 1)
|
||||||
|
os.environ[k] = v
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Determine the agent type (claude, agy, hermes, cline)
|
||||||
|
agent_type = "claude"
|
||||||
|
if agent_cmd:
|
||||||
|
if "claude" in agent_cmd[0]:
|
||||||
|
agent_type = "claude"
|
||||||
|
elif "agy" in agent_cmd[0]:
|
||||||
|
agent_type = "agy"
|
||||||
|
elif "hermes" in agent_cmd[0]:
|
||||||
|
agent_type = "hermes"
|
||||||
|
elif "cline" in agent_cmd[0]:
|
||||||
|
agent_type = "cline"
|
||||||
|
else:
|
||||||
|
# guess from name
|
||||||
|
if "claude" in name:
|
||||||
|
agent_type = "claude"
|
||||||
|
elif "agy" in name:
|
||||||
|
agent_type = "agy"
|
||||||
|
elif "hermes" in name:
|
||||||
|
agent_type = "hermes"
|
||||||
|
elif "cline" in name:
|
||||||
|
agent_type = "cline"
|
||||||
|
|
||||||
|
# TUI Welcome Tokens definition to prevent TUI readiness check timeout
|
||||||
|
buffer_content = {
|
||||||
|
"claude": "Anthropic Claude Ready",
|
||||||
|
"agy": "Antigravity Ready",
|
||||||
|
"hermes": "Hermes Ready",
|
||||||
|
"cline": "Cline Chat Ready"
|
||||||
|
}.get(agent_type, "Ready")
|
||||||
|
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
agents[name] = {
|
||||||
|
"agent": agent_type,
|
||||||
|
"status": "running",
|
||||||
|
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
|
||||||
|
"workspace_id": ws or "w1",
|
||||||
|
"pid": 9999,
|
||||||
|
"pane_id": "w1:p1",
|
||||||
|
"command": " ".join(agent_cmd),
|
||||||
|
"buffer": buffer_content
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate session/conversation UUID and files to simulate agent startup
|
||||||
|
session_uuid = str(uuid.uuid4())
|
||||||
|
own_key_map = {
|
||||||
|
"claude": "claude_session_id_own",
|
||||||
|
"agy": "agy_conversation_id_own",
|
||||||
|
"hermes": "hermes_conversation_id_own",
|
||||||
|
"cline": "cline_conversation_id_own"
|
||||||
|
}
|
||||||
|
own_key = own_key_map.get(agent_type)
|
||||||
|
if own_key:
|
||||||
|
agents[name][own_key] = session_uuid
|
||||||
|
|
||||||
|
# Find isolation directory (e.g. if HOME has agent_homes/<uuid>)
|
||||||
|
home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER")
|
||||||
|
ws_abs = os.path.abspath(cwd or "TMP_PATH_PLACEHOLDER")
|
||||||
|
ws_key = ws_abs.replace('/', '-').replace('_', '-')
|
||||||
|
|
||||||
|
if agent_type == "claude":
|
||||||
|
ccd = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||||
|
if ccd:
|
||||||
|
cp_dir = os.path.join(ccd, "projects")
|
||||||
|
else:
|
||||||
|
cp_dir = os.environ.get("CLAUDE_PROJECT_DIR", f"{home_dir}/.claude/projects")
|
||||||
|
proj_dir = os.path.join(cp_dir, ws_key)
|
||||||
|
os.makedirs(proj_dir, exist_ok=True)
|
||||||
|
jsonl_file = os.path.join(proj_dir, f"{session_uuid}.jsonl")
|
||||||
|
with open(jsonl_file, 'w') as jf:
|
||||||
|
jf.write(json.dumps({"sessionId": session_uuid}) + "\\\\n")
|
||||||
|
elif agent_type == "agy":
|
||||||
|
db_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "conversations")
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
db_file = os.path.join(db_dir, f"{session_uuid}.db")
|
||||||
|
with open(db_file, 'w') as df:
|
||||||
|
df.write("")
|
||||||
|
|
||||||
|
lc_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "cache")
|
||||||
|
os.makedirs(lc_dir, exist_ok=True)
|
||||||
|
lc_file = os.path.join(lc_dir, "last_conversations.json")
|
||||||
|
lc_data = {}
|
||||||
|
if os.path.exists(lc_file):
|
||||||
|
try:
|
||||||
|
with open(lc_file, 'r') as lcf:
|
||||||
|
lc_data = json.load(lcf)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
lc_data[ws_abs] = session_uuid
|
||||||
|
with open(lc_file, 'w') as lcf:
|
||||||
|
json.dump(lc_data, lcf)
|
||||||
|
elif agent_type == "hermes":
|
||||||
|
hermes_dir = os.path.join(home_dir, ".hermes")
|
||||||
|
os.makedirs(hermes_dir, exist_ok=True)
|
||||||
|
hdb = os.path.join(hermes_dir, "state.db")
|
||||||
|
conn = sqlite3.connect(hdb)
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO sessions (id, cwd) VALUES (?, ?)", (session_uuid, ws_abs))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
elif agent_type == "cline":
|
||||||
|
clin_base = os.path.join(home_dir, ".cline", "data", "sessions")
|
||||||
|
if "agent_homes" in home_dir:
|
||||||
|
clin_base = os.path.join(home_dir, "sessions")
|
||||||
|
session_dir = os.path.join(clin_base, session_uuid)
|
||||||
|
os.makedirs(session_dir, exist_ok=True)
|
||||||
|
json_file = os.path.join(session_dir, f"{session_uuid}.json")
|
||||||
|
with open(json_file, 'w') as jf:
|
||||||
|
jf.write(json.dumps({"id": session_uuid}))
|
||||||
|
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
elif cmd2 == "get":
|
||||||
|
if len(args) < 3:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
if name in agents:
|
||||||
|
agent_data = agents[name]
|
||||||
|
pane_info = {
|
||||||
|
"pid": agent_data.get("pid", 9999),
|
||||||
|
"cwd": agent_data.get("cwd", ""),
|
||||||
|
"command": agent_data.get("command", ""),
|
||||||
|
"argv": agent_data.get("command", "")
|
||||||
|
}
|
||||||
|
print(json.dumps({
|
||||||
|
"pane": pane_info,
|
||||||
|
"result": {
|
||||||
|
"agent": {
|
||||||
|
"agent": agent_data.get("agent", "claude"),
|
||||||
|
"status": agent_data.get("status", "running"),
|
||||||
|
"cwd": agent_data.get("cwd", ""),
|
||||||
|
"pane_id": agent_data.get("pane_id", "w1:p1"),
|
||||||
|
"workspace_id": agent_data.get("workspace_id", "w1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||||
|
sys.exit(1)
|
||||||
|
elif cmd2 == "read":
|
||||||
|
if len(args) < 3:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
if name in agents:
|
||||||
|
buffer_content = agents[name].get("buffer", "Ready")
|
||||||
|
print(buffer_content)
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||||
|
sys.exit(1)
|
||||||
|
elif cmd2 == "send":
|
||||||
|
if len(args) < 4:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
text = args[3]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
if name in agents:
|
||||||
|
agents[name]["sent_text"] = agents[name].get("sent_text", "") + text
|
||||||
|
if text == "C-m":
|
||||||
|
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\nesc to interrupt"
|
||||||
|
else:
|
||||||
|
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\n" + text
|
||||||
|
if "/exit" in text or "exit" in text or "Exit" in text:
|
||||||
|
agents[name]["status"] = "stopped"
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.stderr.write("Agent " + name + " not found\\\\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif cmd1 == "session":
|
||||||
|
if len(args) < 3:
|
||||||
|
sys.exit(1)
|
||||||
|
cmd2 = args[1]
|
||||||
|
name = args[2]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
if cmd2 == "stop":
|
||||||
|
if name in agents:
|
||||||
|
agents[name]["status"] = "stopped"
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
elif cmd2 == "delete":
|
||||||
|
if name in agents:
|
||||||
|
del agents[name]
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif cmd1 == "pane":
|
||||||
|
if len(args) < 3:
|
||||||
|
sys.exit(1)
|
||||||
|
cmd2 = args[1]
|
||||||
|
if cmd2 == "send-keys":
|
||||||
|
if len(args) < 4:
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[2]
|
||||||
|
key = args[3]
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
actual_name = name
|
||||||
|
for a_name, data in agents.items():
|
||||||
|
if data.get("pane_id") == name:
|
||||||
|
actual_name = a_name
|
||||||
|
break
|
||||||
|
if actual_name in agents:
|
||||||
|
agents[actual_name]["sent_keys"] = agents[actual_name].get("sent_keys", []) + [key]
|
||||||
|
if key in ("Enter", "C-m"):
|
||||||
|
agents[actual_name]["buffer"] = agents[actual_name].get("buffer", "") + "\\nesc to interrupt"
|
||||||
|
state["agents"] = agents
|
||||||
|
save_state()
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
elif cmd2 == "process-info":
|
||||||
|
pane_id = ""
|
||||||
|
if "--pane" in args:
|
||||||
|
pane_id = args[args.index("--pane") + 1]
|
||||||
|
pid = 9999
|
||||||
|
for name, data in state.get("agents", {}).items():
|
||||||
|
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
|
||||||
|
pid = data.get("pid", 9999)
|
||||||
|
break
|
||||||
|
res = {
|
||||||
|
"process_info": {
|
||||||
|
"foreground_processes": [{"pid": pid}],
|
||||||
|
"shell_pid": pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps({"result": res}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif cmd1 == "ls":
|
||||||
|
if "-F" in args:
|
||||||
|
for name, data in state.get("agents", {}).items():
|
||||||
|
print(f"{name}|999999")
|
||||||
|
sys.exit(0)
|
||||||
|
with open("/tmp/debug_mock_herdr.log", "a") as f_debug:
|
||||||
|
f_debug.write(f"ARGS: {sys.argv[1:]} | AGENTS: {list(state.get('agents', {}).keys())} | PATH: {os.path.exists(state_file)}\\n")
|
||||||
|
agents_list = []
|
||||||
|
for name, data in state.get("agents", {}).items():
|
||||||
|
agents_list.append({
|
||||||
|
"name": name,
|
||||||
|
"agent": data.get("agent", "claude"),
|
||||||
|
"agent_status": data.get("status", "running")
|
||||||
|
})
|
||||||
|
print(json.dumps({"result": {"agents": agents_list}}))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
""".replace("STATE_FILE_PLACEHOLDER", str(state_file)).replace("TMP_PATH_PLACEHOLDER", str(tmp_path))
|
||||||
|
|
||||||
|
herdr_bin.write_text(code)
|
||||||
|
herdr_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# Update PATH using monkeypatch.
|
||||||
|
# Prepend bin_dir, and append standard Homebrew/system paths so lib.sh won't prepend them
|
||||||
|
old_path = os.environ.get("PATH", "")
|
||||||
|
extra_dirs = [
|
||||||
|
"/home/linuxbrew/.linuxbrew/bin",
|
||||||
|
"/home/linuxbrew/.linuxbrew/sbin",
|
||||||
|
f"{tmp_path}/.local/bin",
|
||||||
|
f"{tmp_path}/.npm-global/bin"
|
||||||
|
]
|
||||||
|
new_path = old_path
|
||||||
|
for d in extra_dirs:
|
||||||
|
if d not in new_path:
|
||||||
|
new_path = f"{new_path}:{d}"
|
||||||
|
|
||||||
|
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
|
||||||
|
|
||||||
|
return state_file
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_agents(mam_sandbox, monkeypatch):
|
||||||
|
"""
|
||||||
|
Generates mock agent binaries (claude, agy, hermes, cline)
|
||||||
|
and places them in the sandboxed bin folder to satisfy preflight checks.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. claude mock
|
||||||
|
claude_bin = bin_dir / "claude"
|
||||||
|
claude_bin.write_text("""#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 2 and args[0] == "auth" and args[1] == "status":
|
||||||
|
print(json.dumps({"loggedIn": True}))
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
|
""")
|
||||||
|
claude_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# 2. agy mock
|
||||||
|
agy_bin = bin_dir / "agy"
|
||||||
|
agy_bin.write_text("""#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 1 and args[0] == "models":
|
||||||
|
print("gemini-1.5-pro\\ngemini-1.5-flash")
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
|
""")
|
||||||
|
agy_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# 3. hermes mock
|
||||||
|
hermes_bin = bin_dir / "hermes"
|
||||||
|
hermes_bin.write_text("""#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 1 and args[0] == "status":
|
||||||
|
print("Hermes functional")
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
|
""")
|
||||||
|
hermes_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# 4. cline mock
|
||||||
|
cline_bin = bin_dir / "cline"
|
||||||
|
cline_bin.write_text("""#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if len(args) >= 1 and args[0] == "history":
|
||||||
|
print(json.dumps([]))
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
|
""")
|
||||||
|
cline_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# 5. uuidgen mock to guarantee isolated creation UUIDs
|
||||||
|
uuidgen_bin = bin_dir / "uuidgen"
|
||||||
|
uuidgen_bin.write_text("""#!/usr/bin/env python3
|
||||||
|
import uuid
|
||||||
|
print(str(uuid.uuid4()))
|
||||||
|
""")
|
||||||
|
uuidgen_bin.chmod(0o755)
|
||||||
|
|
||||||
|
# Prepend bin directory to PATH
|
||||||
|
old_path = os.environ.get("PATH", "")
|
||||||
|
extra_dirs = [
|
||||||
|
"/home/linuxbrew/.linuxbrew/bin",
|
||||||
|
"/home/linuxbrew/.linuxbrew/sbin",
|
||||||
|
f"{tmp_path}/.local/bin",
|
||||||
|
f"{tmp_path}/.npm-global/bin"
|
||||||
|
]
|
||||||
|
new_path = old_path
|
||||||
|
for d in extra_dirs:
|
||||||
|
if d not in new_path:
|
||||||
|
new_path = f"{new_path}:{d}"
|
||||||
|
|
||||||
|
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
|
||||||
|
|
||||||
|
return bin_dir
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||||
|
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_key_handling(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Verify unbound key handling: herdr has-session/kill-session with trailing parameters
|
||||||
|
handles it gracefully (returns status 1 with error) instead of crashing with unbound var or shift errors.
|
||||||
|
"""
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||||
|
|
||||||
|
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||||
|
assert shim_path.exists(), "Shim herdr was not created"
|
||||||
|
|
||||||
|
# Run has-session with trailing -t
|
||||||
|
res = subprocess.run([str(shim_path), "has-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||||
|
assert res.returncode == 1
|
||||||
|
assert "Error: -t requires a value" in res.stderr
|
||||||
|
|
||||||
|
# Run kill-session with trailing -t
|
||||||
|
res = subprocess.run([str(shim_path), "kill-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||||
|
assert res.returncode == 1
|
||||||
|
assert "Error: -t requires a value" in res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_fallback_shell(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Verify new-session: launches shell when no command is provided.
|
||||||
|
"""
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||||
|
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||||
|
|
||||||
|
test_shell = "/bin/custom_sh"
|
||||||
|
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), SHELL=test_shell)
|
||||||
|
|
||||||
|
res = subprocess.run([str(shim_path), "new-session", "-s", "fallback-session"], capture_output=True, text=True, env=run_env)
|
||||||
|
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||||
|
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
calls = state.get("calls", [])
|
||||||
|
|
||||||
|
start_call = None
|
||||||
|
for call in calls:
|
||||||
|
if "agent" in call and "start" in call and "fallback-session" in call:
|
||||||
|
start_call = call
|
||||||
|
break
|
||||||
|
assert start_call is not None, f"No agent start call found in: {calls}"
|
||||||
|
assert start_call[-1] == test_shell, f"Expected last arg to be {test_shell}, got {start_call[-1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ls_key_error(mam_sandbox):
|
||||||
|
"""
|
||||||
|
Verify ls key error: parses agent list without 'name' correctly using fallback keys.
|
||||||
|
"""
|
||||||
|
py_code = """
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
res = data.get('result', data)
|
||||||
|
for a in res.get('agents', []):
|
||||||
|
try:
|
||||||
|
name = a.get('name') or a.get('agent') or 'unknown'
|
||||||
|
print(f"{name}|0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
input_json = json.dumps({
|
||||||
|
"result": {
|
||||||
|
"agents": [
|
||||||
|
{"agent": "claude", "status": "running"},
|
||||||
|
{"name": "agent-with-name", "agent": "agy"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res = subprocess.run([sys.executable, "-c", py_code], input=input_json, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
lines = res.stdout.strip().split('\n')
|
||||||
|
assert "claude|0" in lines
|
||||||
|
assert "agent-with-name|0" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_relocatability(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Verify relocatability: reconcile.sh runs when relocated or inside different paths.
|
||||||
|
"""
|
||||||
|
reconcile_script = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
run_dir = mam_sandbox / "some_other_dir"
|
||||||
|
run_dir.mkdir()
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], cwd=str(run_dir), capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||||
|
assert res.returncode == 0, f"Failed when run from different directory. Stderr: {res.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_server_mapping(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Verify workspace/server mapping: status output workspace is correct.
|
||||||
|
"""
|
||||||
|
status_script = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
session_name = "test-mapping-sess-creator-claude"
|
||||||
|
|
||||||
|
mutation = f"""
|
||||||
|
d['herdr_sessions'] = [{{
|
||||||
|
'name': '{session_name}',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'Creator',
|
||||||
|
'herdr_workspace': 'my-custom-workspace',
|
||||||
|
'pane': {{
|
||||||
|
'cwd': 'WS_PLACEHOLDER',
|
||||||
|
'pid': 7777,
|
||||||
|
'cmd': 'claude',
|
||||||
|
'cmd_full': 'claude'
|
||||||
|
}}
|
||||||
|
}}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
res_mut = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True, cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
|
||||||
|
assert res.returncode == 0, f"Stderr: {res.stderr}"
|
||||||
|
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
sess_detail = data["sessions_detail"]
|
||||||
|
target_sess = [s for s in sess_detail if s["name"] == session_name][0]
|
||||||
|
|
||||||
|
assert target_sess["server"] == "my-custom-workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def test_variable_splicing_injection_safety(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Verify variable splicing injection: no vulnerabilities or syntax errors remain.
|
||||||
|
"""
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
|
||||||
|
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
|
||||||
|
|
||||||
|
adversarial_name = 'my"server; import os; os.system("echo INJECTED")'
|
||||||
|
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), HERDR_SERVER_NAME=adversarial_name)
|
||||||
|
|
||||||
|
res = subprocess.run([str(shim_path), "new-session", "-s", "injection-test-session"], capture_output=True, text=True, env=run_env)
|
||||||
|
assert res.returncode == 0, f"Failed with adversarial HERDR_SERVER_NAME. Stderr: {res.stderr}"
|
||||||
|
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
assert any("injection-test-session" in call for call in state["calls"]), "Expected injection-test-session call to succeed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_masking_exit_code_preservation():
|
||||||
|
"""
|
||||||
|
Verify export masking: exit codes are preserved when assigning and exporting.
|
||||||
|
"""
|
||||||
|
# 1. Export masked version exits 0 (silent fail)
|
||||||
|
cmd_masked = ["bash", "-c", "set -e; export TEST_VAR=$(false); echo 'survived'"]
|
||||||
|
res_masked = subprocess.run(cmd_masked, capture_output=True, text=True)
|
||||||
|
assert res_masked.returncode == 0
|
||||||
|
assert res_masked.stdout.strip() == "survived"
|
||||||
|
|
||||||
|
# 2. Fixed split version exits non-zero (preserves failure exit code)
|
||||||
|
cmd_fixed = ["bash", "-c", "set -e; TEST_VAR=$(false); export TEST_VAR; echo 'survived'"]
|
||||||
|
res_fixed = subprocess.run(cmd_fixed, capture_output=True, text=True)
|
||||||
|
assert res_fixed.returncode != 0
|
||||||
|
assert res_fixed.stdout.strip() != "survived"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
def test_create_session_dry_run(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Sanity test verifying that create_session.sh --dry-run parses inputs,
|
||||||
|
invokes herdr's preflight checks, and exits successfully without side effects.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"bash", str(script_path),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator",
|
||||||
|
"--dry-run"
|
||||||
|
]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||||
|
assert "[dry-run] would provision isolation" in res.stdout
|
||||||
|
assert "[dry-run] would spawn" in res.stdout
|
||||||
|
|
||||||
|
# Verify that mock herdr was called for checking session existence (which gets translated to agent get)
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
calls = state.get("calls", [])
|
||||||
|
assert any("agent" in call and "get" in call for call in calls), f"Calls: {calls}"
|
||||||
|
|
||||||
|
def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Sanity test verifying that a full create_session.sh run intercepts herdr calls,
|
||||||
|
completes the TUI readiness check using mock responses, and serializes state
|
||||||
|
correctly to the sandboxed agent-sessions.yaml registry.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"bash", str(script_path),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator"
|
||||||
|
]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||||
|
|
||||||
|
# 1. Verify mock herdr state file registers the agent session
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
agents = state.get("agents", {})
|
||||||
|
assert len(agents) == 1, "There should be exactly one registered agent in the mock herdr state."
|
||||||
|
|
||||||
|
session_name = list(agents.keys())[0]
|
||||||
|
assert session_name.endswith("-creator-claude")
|
||||||
|
assert agents[session_name]["status"] == "running"
|
||||||
|
assert agents[session_name]["agent"] == "claude"
|
||||||
|
|
||||||
|
# 2. Verify that agent-sessions.yaml is successfully written
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
assert yaml_path.exists(), "The agent-sessions.yaml configuration file was not created."
|
||||||
|
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
|
||||||
|
sessions = reg.get("herdr_sessions", [])
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0]["name"] == session_name
|
||||||
|
assert sessions[0]["status"] == "running"
|
||||||
|
assert sessions[0]["role"] == "Creator"
|
||||||
|
assert sessions[0]["isolation"]["uuid"] is not None
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Helper to run bash snippets sourcing lib.sh
|
||||||
|
def run_lib_func(mam_sandbox, func_name, *args, env=None):
|
||||||
|
lib_path = mam_sandbox / "skills" / "lib.sh"
|
||||||
|
cmd_str = f"source {lib_path} && {func_name} " + " ".join(shlex.quote(str(a)) for a in args)
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, env=run_env)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_mqtt_common(mam_sandbox):
|
||||||
|
script_path = str(mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
||||||
|
if script_path not in sys.path:
|
||||||
|
sys.path.insert(0, script_path)
|
||||||
|
import mqtt_common
|
||||||
|
return mqtt_common
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 1: Create Session (7 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_create_derive_session_name_standard(mam_sandbox):
|
||||||
|
"""Test standard derive_session_name slug generation."""
|
||||||
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/user/project", "claude")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "user-project-creator-claude"
|
||||||
|
|
||||||
|
def test_create_derive_session_name_nested(mam_sandbox):
|
||||||
|
"""Test derive_session_name with nested paths, upper casing, and underscores."""
|
||||||
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/User_Name/My_New_Project", "agy")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "user-name-my-new-project-creator-agy"
|
||||||
|
|
||||||
|
def test_create_derive_session_name_weird_characters(mam_sandbox):
|
||||||
|
"""Test derive_session_name with spaces and punctuation in the path."""
|
||||||
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/a/b c/d-e!f", "hermes")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
|
||||||
|
|
||||||
|
def test_create_isolation_lever(mam_sandbox):
|
||||||
|
"""Test isolation_lever outputs for each supported agent."""
|
||||||
|
agents = {
|
||||||
|
"claude": "claude_config_dir",
|
||||||
|
"cline": "cline_data_dir",
|
||||||
|
"agy": "home",
|
||||||
|
"hermes": "home",
|
||||||
|
"unknown": ""
|
||||||
|
}
|
||||||
|
for agent, expected in agents.items():
|
||||||
|
res = run_lib_func(mam_sandbox, "isolation_lever", agent)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == expected
|
||||||
|
|
||||||
|
def test_create_isolation_env_prefix(mam_sandbox):
|
||||||
|
"""Test isolation_env_prefix format outputs."""
|
||||||
|
res = run_lib_func(mam_sandbox, "isolation_env_prefix", "claude", "/tmp/iso")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout == "CLAUDE_CONFIG_DIR=/tmp/iso "
|
||||||
|
|
||||||
|
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
|
||||||
|
assert res2.returncode == 0
|
||||||
|
assert res2.stdout == "HOME=/tmp/iso "
|
||||||
|
|
||||||
|
res3 = run_lib_func(mam_sandbox, "isolation_env_prefix", "cline", "/tmp/iso")
|
||||||
|
assert res3.returncode == 0
|
||||||
|
assert res3.stdout == ""
|
||||||
|
|
||||||
|
def test_create_isolation_cmd_args(mam_sandbox):
|
||||||
|
"""Test isolation_cmd_args format outputs."""
|
||||||
|
res = run_lib_func(mam_sandbox, "isolation_cmd_args", "cline", "/tmp/iso")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout == "--data-dir /tmp/iso"
|
||||||
|
|
||||||
|
res2 = run_lib_func(mam_sandbox, "isolation_cmd_args", "claude", "/tmp/iso")
|
||||||
|
assert res2.returncode == 0
|
||||||
|
assert res2.stdout == ""
|
||||||
|
|
||||||
|
def test_create_validate_env_key(mam_sandbox):
|
||||||
|
"""Test _validate_env_key function with valid and blocked environment keys."""
|
||||||
|
# Valid key
|
||||||
|
res = run_lib_func(mam_sandbox, "_validate_env_key", "MY_VALID_KEY")
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Malformed key (starts with number)
|
||||||
|
res2 = run_lib_func(mam_sandbox, "_validate_env_key", "123BAD")
|
||||||
|
assert res2.returncode != 0
|
||||||
|
|
||||||
|
# Blocked key (LD_PRELOAD)
|
||||||
|
res3 = run_lib_func(mam_sandbox, "_validate_env_key", "LD_PRELOAD")
|
||||||
|
assert res3.returncode != 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 2: Resume Session (6 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_resume_resolve_herdr_workspace_default(mam_sandbox):
|
||||||
|
"""Test resolve_herdr_workspace fallback behavior when session is not in YAML."""
|
||||||
|
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "default"
|
||||||
|
|
||||||
|
def test_resume_resolve_herdr_workspace_env(mam_sandbox):
|
||||||
|
"""Test resolve_herdr_workspace fallback to HERDR_SERVER_NAME env var."""
|
||||||
|
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "custom_server"
|
||||||
|
|
||||||
|
def test_resume_find_workspace_uuid_empty(mam_sandbox):
|
||||||
|
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
|
||||||
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", "/non/existent/path", "claude")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == ""
|
||||||
|
|
||||||
|
def test_resume_find_workspace_uuid_target_non_existent(mam_sandbox):
|
||||||
|
"""Test target session query with target that does not exist in YAML."""
|
||||||
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "claude", "non-existent-session")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == ""
|
||||||
|
|
||||||
|
def test_resume_find_workspace_uuid_invalid_agent(mam_sandbox):
|
||||||
|
"""Test find_workspace_uuid behavior with an unsupported agent name."""
|
||||||
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "invalidagent")
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == ""
|
||||||
|
|
||||||
|
def test_resume_script_invalid_args(mam_sandbox):
|
||||||
|
"""Test calling resolve_session_id.sh with missing arguments."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--workspace", str(mam_sandbox)], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --agent required" in res.stderr
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 3: Stop Session (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_stop_check_is_nfs_local(mam_sandbox):
|
||||||
|
"""Test that _check_is_nfs on local temp directory returns non-zero (not NFS)."""
|
||||||
|
res = run_lib_func(mam_sandbox, "_check_is_nfs", str(mam_sandbox))
|
||||||
|
# It will exit with 1 if it is not NFS
|
||||||
|
assert res.returncode == 1
|
||||||
|
|
||||||
|
def test_stop_is_already_stopped_not_found(mam_sandbox):
|
||||||
|
"""Test is_already_stopped exits with 1 when session is not in YAML."""
|
||||||
|
res = run_lib_func(mam_sandbox, "is_already_stopped", "non-existent-session")
|
||||||
|
assert res.returncode == 1
|
||||||
|
|
||||||
|
def test_stop_session_invalid_agent_suffix(mam_sandbox):
|
||||||
|
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: cannot infer agent" in res.stderr
|
||||||
|
|
||||||
|
def test_stop_session_missing_required_args(mam_sandbox):
|
||||||
|
"""Test stop_session.sh fails when session name is missing."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --session required" in res.stderr
|
||||||
|
|
||||||
|
def test_stop_session_purge_no_yes(mam_sandbox):
|
||||||
|
"""Test stop_session.sh exits with 3 when purge is requested without --yes."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
# Seed yaml with the session first to avoid "not in yaml" exit 1
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text("""herdr_sessions:
|
||||||
|
- name: test-project-creator-claude
|
||||||
|
status: running
|
||||||
|
pane:
|
||||||
|
cwd: /tmp
|
||||||
|
""")
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-project-creator-claude", "--purge-conversation"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 3
|
||||||
|
assert "DANGER: --purge-conversation will DELETE" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 4: Status Query (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_status_json_schema_fields(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""Verify status.sh output JSON contains the expected structure."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
|
||||||
|
# Run status.sh with --json
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
assert "timestamp" in data
|
||||||
|
assert "yaml_path" in data
|
||||||
|
assert "drifts" in data
|
||||||
|
assert "sessions_detail" in data
|
||||||
|
|
||||||
|
def test_status_text_headers_presence(mam_sandbox):
|
||||||
|
"""Verify status.sh output in text mode includes header columns."""
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "NAME" in res.stdout
|
||||||
|
assert "WORKSPACE" in res.stdout
|
||||||
|
assert "YAML" in res.stdout
|
||||||
|
assert "HERDR" in res.stdout
|
||||||
|
assert "DRIFT" in res.stdout
|
||||||
|
|
||||||
|
def test_status_resume_on_disk_helper_claude(mam_sandbox):
|
||||||
|
"""Verify resume_on_disk behavior for claude inside status.sh logic via mock YAML queries."""
|
||||||
|
# We can write a custom yaml and check status
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text("""herdr_sessions:
|
||||||
|
- name: test-project-creator-claude
|
||||||
|
status: running
|
||||||
|
claude_session_id_own: some-uuid
|
||||||
|
pane:
|
||||||
|
cwd: /tmp/nonexistent-workspace
|
||||||
|
""")
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
detail = data["sessions_detail"][0]
|
||||||
|
assert detail["name"] == "test-project-creator-claude"
|
||||||
|
assert detail["resume_state"] == "MISSING"
|
||||||
|
|
||||||
|
def test_status_resume_on_disk_helper_agy(mam_sandbox):
|
||||||
|
"""Verify resume_on_disk behavior for agy inside status.sh logic."""
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text("""herdr_sessions:
|
||||||
|
- name: test-project-creator-agy
|
||||||
|
status: running
|
||||||
|
agy_conversation_id_own: some-uuid
|
||||||
|
pane:
|
||||||
|
cwd: /tmp/nonexistent-workspace
|
||||||
|
""")
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
detail = data["sessions_detail"][0]
|
||||||
|
assert detail["name"] == "test-project-creator-agy"
|
||||||
|
assert detail["resume_state"] == "MISSING"
|
||||||
|
|
||||||
|
def test_status_get_job_status_helper(mam_sandbox):
|
||||||
|
"""Verify get_job_status parses non-existent jobs gracefully."""
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
yaml_path.write_text("""herdr_sessions:
|
||||||
|
- name: test-project-creator-claude
|
||||||
|
status: running
|
||||||
|
delegate_job_id: nonexistent-job-id
|
||||||
|
pane:
|
||||||
|
cwd: /tmp
|
||||||
|
""")
|
||||||
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
detail = data["sessions_detail"][0]
|
||||||
|
assert detail["job_id"] == "nonexistent-job-id"
|
||||||
|
assert detail["job_status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_mqtt_topic_prefix_for(mam_sandbox):
|
||||||
|
"""Test topic prefix helper methods in mqtt_common."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
prefix = mqtt_common.topic_prefix_for("job123")
|
||||||
|
assert prefix == "python/mqtt/jobs/job123"
|
||||||
|
|
||||||
|
events_topic = mqtt_common.events_topic_for("job123")
|
||||||
|
assert events_topic == "python/mqtt/jobs/job123/events"
|
||||||
|
|
||||||
|
def test_mqtt_verify_hmac_no_token(mam_sandbox):
|
||||||
|
"""Verify verify_hmac returns True when no token is present."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
payload = {"data": {"hmac_sig": "somesig"}}
|
||||||
|
assert mqtt_common.verify_hmac(payload, None) is True
|
||||||
|
assert mqtt_common.verify_hmac(payload, "") is True
|
||||||
|
|
||||||
|
def test_mqtt_verify_hmac_valid_invalid(mam_sandbox):
|
||||||
|
"""Verify verify_hmac signature validation matching logic."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
token = "secret_key"
|
||||||
|
payload = {
|
||||||
|
"job_id": "job1",
|
||||||
|
"event": "started",
|
||||||
|
"seq": 1,
|
||||||
|
"timestamp": "2026-07-19T00:00:00Z",
|
||||||
|
"data": {
|
||||||
|
"some_key": "some_val"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Calculate HMAC
|
||||||
|
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
sig = hmac.new(token.encode(), msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
# Put HMAC signature inside data block
|
||||||
|
payload["data"]["hmac_sig"] = sig
|
||||||
|
|
||||||
|
assert mqtt_common.verify_hmac(payload, token) is True
|
||||||
|
|
||||||
|
# Modifying payload should cause verification to fail
|
||||||
|
payload["seq"] = 2
|
||||||
|
assert mqtt_common.verify_hmac(payload, token) is False
|
||||||
|
|
||||||
|
def test_mqtt_reason_code_value(mam_sandbox):
|
||||||
|
"""Verify reason_code_value correctly extracts values from paho reason codes."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
|
||||||
|
# Test plain int
|
||||||
|
assert mqtt_common.reason_code_value(0) == 0
|
||||||
|
assert mqtt_common.reason_code_value(5) == 5
|
||||||
|
|
||||||
|
# Test object with .value attribute
|
||||||
|
class DummyReasonCode:
|
||||||
|
def __init__(self, val):
|
||||||
|
self.value = val
|
||||||
|
assert mqtt_common.reason_code_value(DummyReasonCode(0)) == 0
|
||||||
|
assert mqtt_common.reason_code_value(DummyReasonCode(16)) == 16
|
||||||
|
|
||||||
|
def test_mqtt_with_retry_success(mam_sandbox):
|
||||||
|
"""Verify with_retry decorator works on direct success."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
@mqtt_common.with_retry(attempts=3)
|
||||||
|
def dummy_func(x):
|
||||||
|
calls.append(x)
|
||||||
|
return x * 2
|
||||||
|
|
||||||
|
res = dummy_func(5)
|
||||||
|
assert res == 10
|
||||||
|
assert calls == [5]
|
||||||
|
|
||||||
|
def test_mqtt_with_retry_failure(mam_sandbox):
|
||||||
|
"""Verify with_retry decorator raises error after specified attempts."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
@mqtt_common.with_retry(attempts=3, base_delay=0.01)
|
||||||
|
def failing_func():
|
||||||
|
calls.append(1)
|
||||||
|
raise ValueError("failing")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="failing"):
|
||||||
|
failing_func()
|
||||||
|
assert len(calls) == 3
|
||||||
@@ -0,0 +1,734 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||||
|
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_mqtt_common(mam_sandbox):
|
||||||
|
script_path = str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
||||||
|
if script_path not in sys.path:
|
||||||
|
sys.path.insert(0, script_path)
|
||||||
|
import mqtt_common
|
||||||
|
return mqtt_common
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 1: Create Session (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_comp_create_schema_validation(mam_sandbox):
|
||||||
|
"""Verify that atomic_dump_yaml validates the schema and rejects malformed formats."""
|
||||||
|
# Try setting herdr_sessions to a dictionary instead of list
|
||||||
|
mutation = "d['herdr_sessions'] = {}"
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert "VALIDATE: herdr_sessions is not a list" in res.stderr
|
||||||
|
|
||||||
|
# Try setting status to an invalid state
|
||||||
|
mutation_invalid_status = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'session1',
|
||||||
|
'status': 'invalidstatus',
|
||||||
|
'pane': {}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
res2 = run_mutation(mam_sandbox, mutation_invalid_status)
|
||||||
|
assert "VALIDATE:" in res2.stderr
|
||||||
|
|
||||||
|
def test_comp_create_role_immutability(mam_sandbox):
|
||||||
|
"""Verify that once a role is defined in the sessions, it cannot be mutated."""
|
||||||
|
# 1. Setup session with a role
|
||||||
|
mutation_setup = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'session-imm-role',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'Creator',
|
||||||
|
'pane': {}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation_setup)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# 2. Try mutating the role
|
||||||
|
mutation_bad = """
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if s.get('name') == 'session-imm-role':
|
||||||
|
s['role'] = 'Reviewer'
|
||||||
|
"""
|
||||||
|
res_bad = run_mutation(mam_sandbox, mutation_bad)
|
||||||
|
assert res_bad.returncode != 0
|
||||||
|
assert "role of session 'session-imm-role' cannot be modified" in res_bad.stderr
|
||||||
|
|
||||||
|
def test_comp_create_duplicate_running_ids(mam_sandbox):
|
||||||
|
"""Verify that duplicate running conversation IDs across sessions are rejected."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [
|
||||||
|
{
|
||||||
|
'name': 'sess1',
|
||||||
|
'status': 'running',
|
||||||
|
'claude_session_id_own': 'uuid-1234',
|
||||||
|
'pane': {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'sess2',
|
||||||
|
'status': 'running',
|
||||||
|
'claude_session_id_own': 'uuid-1234',
|
||||||
|
'pane': {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res.returncode != 0
|
||||||
|
assert "Duplicate running conversation ID" in res.stderr
|
||||||
|
|
||||||
|
def test_comp_create_isolation_folder_setup(mam_sandbox):
|
||||||
|
"""Verify that provision_isolation correctly creates directories and symlinks."""
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
iso_root = mam_sandbox / "iso_home_test"
|
||||||
|
|
||||||
|
# Mock global claude credentials
|
||||||
|
claude_cred = mam_sandbox / ".claude"
|
||||||
|
claude_cred.mkdir(parents=True, exist_ok=True)
|
||||||
|
(claude_cred / ".credentials.json").write_text('{"token": "xyz"}')
|
||||||
|
|
||||||
|
cmd_str = f"source {lib_path} && provision_isolation claude {iso_root}"
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Verify symlink exists and points to the credentials
|
||||||
|
cred_sym = iso_root / ".credentials.json"
|
||||||
|
assert cred_sym.is_symlink()
|
||||||
|
assert cred_sym.read_text() == '{"token": "xyz"}'
|
||||||
|
|
||||||
|
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""Verify that tables exist and contain records after a full create_session.sh run."""
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"bash", str(script_path),
|
||||||
|
"--workspace", str(mam_sandbox),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator"
|
||||||
|
]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
assert db_path.exists()
|
||||||
|
|
||||||
|
# Connect directly to the SQLite DB
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check tables
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||||
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
|
assert "state" in tables
|
||||||
|
assert "sessions" in tables
|
||||||
|
|
||||||
|
# Check entries in sessions table
|
||||||
|
cursor.execute("SELECT name, status, pane_cwd FROM sessions;")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0][0].endswith("-creator-claude")
|
||||||
|
assert rows[0][1] == "running"
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 2: Resume Session (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_comp_resume_config_restore(mam_sandbox):
|
||||||
|
"""Verify that isolation details are preserved and can be read from the DB registry."""
|
||||||
|
# Write a test session with isolation details
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-session-iso',
|
||||||
|
'status': 'stopped',
|
||||||
|
'pane': {'cwd': '/tmp'},
|
||||||
|
'isolation': {
|
||||||
|
'uuid': 'iso-uuid-789',
|
||||||
|
'root': '/tmp/iso_root',
|
||||||
|
'lever': 'claude_config_dir',
|
||||||
|
'seeded': []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Read the configuration via resume_session.sh isolation resolution check
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||||
|
# We run in dry-run/mock mode or just execute python command checking block
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
|
||||||
|
# Call the python parsing block from resume_session.sh directly
|
||||||
|
python_block = """
|
||||||
|
import os, json, sqlite3
|
||||||
|
name = "test-session-iso"
|
||||||
|
db_path = "DB_PATH_PLACEHOLDER"
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||||
|
if row:
|
||||||
|
import json
|
||||||
|
s = json.loads(row[0])
|
||||||
|
print(json.dumps(s.get('isolation') or {}))
|
||||||
|
""".replace("DB_PATH_PLACEHOLDER", str(db_path))
|
||||||
|
|
||||||
|
res_py = subprocess.run(["python3", "-c", python_block], capture_output=True, text=True)
|
||||||
|
assert res_py.returncode == 0
|
||||||
|
data = json.loads(res_py.stdout)
|
||||||
|
assert data["uuid"] == "iso-uuid-789"
|
||||||
|
assert data["root"] == "/tmp/iso_root"
|
||||||
|
|
||||||
|
def test_comp_resume_metadata_read_integrity(mam_sandbox):
|
||||||
|
"""Verify metadata read integrity from the SQLite DB by changing state outside YAML."""
|
||||||
|
# Write initial YAML state
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'sess-integrity',
|
||||||
|
'status': 'stopped',
|
||||||
|
'pane': {'cwd': '/tmp'}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
# Update SQLite directly to terminated
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("UPDATE sessions SET status='terminated' WHERE name='sess-integrity'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Read via atomic_dump_yaml check (which pulls from DB sessions table)
|
||||||
|
# If the DB reading has integrity, d will contain sess-integrity as status 'terminated'
|
||||||
|
mutation_check = """
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if s.get('name') == 'sess-integrity':
|
||||||
|
print(f"STATUS={s['status']}")
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation_check)
|
||||||
|
assert "STATUS=terminated" in res.stdout
|
||||||
|
|
||||||
|
def test_comp_resume_update_yaml(mam_sandbox):
|
||||||
|
"""Verify that update_yaml_resumed.sh cleans up stop fields and marks status running."""
|
||||||
|
# Seed a stopped session with stop metadata
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-resumed-session-creator-claude',
|
||||||
|
'status': 'stopped',
|
||||||
|
'stopped_at': '2026-07-19T00:00:00Z',
|
||||||
|
'stop_reason': 'manual_stop',
|
||||||
|
'pane': {'cwd': '/tmp'}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-resumed-session-creator-claude", "--uuid", "new-uuid-999"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Verify stopped fields are popped
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT data FROM sessions WHERE name='test-resumed-session-creator-claude'").fetchone()
|
||||||
|
s = json.loads(row[0])
|
||||||
|
assert s["status"] == "running"
|
||||||
|
assert "stopped_at" not in s
|
||||||
|
assert "stop_reason" not in s
|
||||||
|
assert s["claude_session_id_own"] == "new-uuid-999"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_comp_resume_find_workspace_uuid_tier1_own_id(mam_sandbox):
|
||||||
|
"""Verify find_workspace_uuid resolves the per-row own ID properly."""
|
||||||
|
# Write a running session with a claude_session_id_own
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'ws-own-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'claude_session_id_own': 'own-uuid-111',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
# Target session mode scopes resolution to the row
|
||||||
|
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude ws-own-creator-claude"
|
||||||
|
# Since we are mock-checking, own_exists function mock will query disk format.
|
||||||
|
# Claude disk format requires a project file: projects/<key>/<uuid>.jsonl
|
||||||
|
# Let's create it.
|
||||||
|
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||||
|
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||||
|
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(proj_dir / "own-uuid-111.jsonl").write_text('{"sessionId": "own-uuid-111"}')
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
# Should resolve to own-uuid-111 because it exists in projects folder
|
||||||
|
assert res.stdout.strip() == "own-uuid-111"
|
||||||
|
|
||||||
|
def test_comp_resume_find_workspace_uuid_tier2_disk_scan_claude(mam_sandbox):
|
||||||
|
"""Verify find_workspace_uuid scans workspace on disk if own ID isn't set in the row."""
|
||||||
|
# Seed session without own ID
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'ws-scan-creator-claude',
|
||||||
|
'status': 'stopped',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
# Create JSONL file
|
||||||
|
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
|
||||||
|
proj_dir = mam_sandbox / ".claude" / "projects" / key
|
||||||
|
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(proj_dir / "scanned-uuid.jsonl").write_text('{"sessionId": "scanned-uuid"}')
|
||||||
|
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude"
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "scanned-uuid"
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 3: Stop Session (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_comp_stop_safe_path_checking(mam_sandbox):
|
||||||
|
"""Verify path guards block directory deletion if isolation path check fails."""
|
||||||
|
# Mock a terminated session where isolation root is set outside .mam folder
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-purge-guard-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
||||||
|
'isolation': {
|
||||||
|
'uuid': 'some-uuid',
|
||||||
|
'root': '/tmp/unauthorized_path_outside_mam'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "WARN: isolated home path check failed" in res.stdout
|
||||||
|
|
||||||
|
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||||
|
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-stop-sqlite-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-stop-sqlite-creator-claude"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Query database directly
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT status, data FROM sessions WHERE name='test-stop-sqlite-creator-claude'").fetchone()
|
||||||
|
assert row[0] == "stopped"
|
||||||
|
s = json.loads(row[1])
|
||||||
|
assert s["status"] == "stopped"
|
||||||
|
assert "stopped_at" in s
|
||||||
|
assert s["stop_reason"] == "manual_stop"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_comp_stop_purge_record_removal(mam_sandbox):
|
||||||
|
"""Verify that purge completely removes the session record from DB."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-purge-record-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-record-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Check SQLite
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT COUNT(*) FROM sessions WHERE name='test-purge-record-creator-claude'").fetchone()
|
||||||
|
assert row[0] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_comp_stop_lock_release(mam_sandbox):
|
||||||
|
"""Verify SQLite lock is released after running atomic_dump_yaml."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-lock-release',
|
||||||
|
'status': 'stopped',
|
||||||
|
'pane': {'cwd': '/tmp'}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Since res has returned, the lock should be free.
|
||||||
|
# We test this by immediately acquiring a direct SQLite write connection
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path), timeout=0.1)
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
conn.execute("UPDATE sessions SET status='archived' WHERE name='test-lock-release'")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
pytest.fail(f"Could not acquire SQLite lock: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_comp_stop_graceful_kill_chain(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""Verify that the graceful stop command fallback chain runs when herdr has-session is alive."""
|
||||||
|
# Write a running session in YAML
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-graceful-chain-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER', 'pid': 12345}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
# Pre-populate herdr mock with the running session so it passes has-session check
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
state["agents"]["test-graceful-chain-creator-claude"] = {
|
||||||
|
"status": "running",
|
||||||
|
"agent": "claude",
|
||||||
|
"cwd": str(mam_sandbox),
|
||||||
|
"pid": 12345,
|
||||||
|
"pane_id": "w1:p1",
|
||||||
|
"command": "claude",
|
||||||
|
"buffer": "Anthropic Claude Ready"
|
||||||
|
}
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
# Run stop_session.sh. This should call send-keys first
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--session", "test-graceful-chain-creator-claude"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
# Verify mock herdr calls recorded the keys "/exit" sent
|
||||||
|
calls = state.get("calls", [])
|
||||||
|
|
||||||
|
# Should see send-keys call
|
||||||
|
assert any("send" in call and "/exit" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 4: Status Query (5 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_comp_status_read_lock(mam_sandbox):
|
||||||
|
"""Verify status.sh accesses SQLite database with a read lock, not writing."""
|
||||||
|
# Seed DB first so it exists
|
||||||
|
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||||
|
|
||||||
|
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
|
||||||
|
# Run status.sh in a background sub-process
|
||||||
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Verify no writing occurred by comparing DB file modified time
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
t1 = os.path.getmtime(db_path)
|
||||||
|
|
||||||
|
# Run status.sh again
|
||||||
|
subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
||||||
|
t2 = os.path.getmtime(db_path)
|
||||||
|
|
||||||
|
assert t1 == t2
|
||||||
|
|
||||||
|
def test_comp_status_drift_class_parsing(mam_sandbox):
|
||||||
|
"""Verify status.sh correctly parses drifts returned by reconcile.sh."""
|
||||||
|
# Write reconcile mock logic that outputs a drift JSON
|
||||||
|
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
|
||||||
|
drift_output = {
|
||||||
|
"timestamp": "2026-07-19T00:00:00Z",
|
||||||
|
"yaml_path": str(mam_sandbox / ".mam" / "agent-sessions.yaml"),
|
||||||
|
"herdr_sessions_alive": ["test-session|default"],
|
||||||
|
"herdr_confirmed": True,
|
||||||
|
"drifts": [{"class": "A", "name": "test-session", "msg": "drift detected"}],
|
||||||
|
"actions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Overwrite reconcile.sh to print this JSON directly
|
||||||
|
reconcile_script.write_text(f"""#!/usr/bin/env bash
|
||||||
|
echo '{json.dumps(drift_output)}'
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed YAML
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-session',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': '/tmp'}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
assert data["sessions_detail"][0]["drift_classes"] == ["A"]
|
||||||
|
|
||||||
|
def test_comp_status_job_candidates_parsing(mam_sandbox):
|
||||||
|
"""Verify status.sh reads job statuses from audit logs or registry JSONs."""
|
||||||
|
# Write YAML session
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-job-session-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'delegate_job_id': 'job12345',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
# Create the status.json in both candidate folders to cover path resolutions
|
||||||
|
job_logs_dir1 = mam_sandbox / ".mam" / "delegate_job_logs" / "job12345"
|
||||||
|
job_logs_dir1.mkdir(parents=True, exist_ok=True)
|
||||||
|
(job_logs_dir1 / "status.json").write_text('{"status": "completed"}')
|
||||||
|
|
||||||
|
job_logs_dir2 = mam_sandbox.parent / ".mam" / "delegate_job_logs" / "job12345"
|
||||||
|
job_logs_dir2.mkdir(parents=True, exist_ok=True)
|
||||||
|
(job_logs_dir2 / "status.json").write_text('{"status": "completed"}')
|
||||||
|
|
||||||
|
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
assert data["sessions_detail"][0]["job_status"] == "completed"
|
||||||
|
|
||||||
|
def test_comp_status_no_side_effects(mam_sandbox):
|
||||||
|
"""Verify that running status.sh leaves YAML and DB unchanged."""
|
||||||
|
# Seed DB first so it exists
|
||||||
|
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
|
||||||
|
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
|
||||||
|
y_content_1 = yaml_path.read_text()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
d_content_1 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
subprocess.run(["bash", str(status_script)], capture_output=True, text=True)
|
||||||
|
|
||||||
|
y_content_2 = yaml_path.read_text()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
d_content_2 = conn.execute("SELECT * FROM sessions").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert y_content_1 == y_content_2
|
||||||
|
assert d_content_1 == d_content_2
|
||||||
|
|
||||||
|
def test_comp_status_yaml_to_json_structure(mam_sandbox):
|
||||||
|
"""Verify YAML-to-JSON structure translation preserves all nested maps."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-struct-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {
|
||||||
|
'index': 0,
|
||||||
|
'pid': 1234,
|
||||||
|
'cmd': 'claude',
|
||||||
|
'cwd': '/tmp'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
session = data["sessions_detail"][0]
|
||||||
|
assert session["pane_pid"] == 1234
|
||||||
|
assert session["pane_cwd"] == "/tmp"
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def test_comp_monitor_concurrency_lock(mam_sandbox):
|
||||||
|
"""Verify that multiple subscriber reconciles cannot run concurrently."""
|
||||||
|
# Reconcile subscribe script tries to acquire .mam/monitor.lock
|
||||||
|
# Let's write a mock subscriber that acquires the lock
|
||||||
|
workspace_root = mam_sandbox
|
||||||
|
lock_file_path = workspace_root / ".mam" / "monitor.lock"
|
||||||
|
|
||||||
|
# Hold lock in Python
|
||||||
|
import fcntl
|
||||||
|
lock_file = open(lock_file_path, 'w')
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
|
||||||
|
# Now execute reconcile subscribe loop, it should exit immediately
|
||||||
|
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(reconcile_script), "--subscribe", "--idle-timeout", "1"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert "MQTT Monitor: another subscriber is already running" in res.stdout
|
||||||
|
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
def test_comp_monitor_sqlite_journal_wal(mam_sandbox):
|
||||||
|
"""Verify that SQLite database defaults to WAL mode on standard filesystems."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = []
|
||||||
|
"""
|
||||||
|
# Runs atomic_dump_yaml which sets journal mode
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert mode.lower() == "wal"
|
||||||
|
|
||||||
|
def test_comp_monitor_sqlite_journal_delete_nfs(mam_sandbox):
|
||||||
|
"""Verify that SQLite database falls back to DELETE mode on NFS filesystems."""
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = []
|
||||||
|
"""
|
||||||
|
# Set MAM_IS_NFS env var
|
||||||
|
res = run_mutation(mam_sandbox, mutation, env={"MAM_IS_NFS": "true"})
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert mode.lower() == "delete"
|
||||||
|
|
||||||
|
def test_comp_monitor_db_schema_auto_update(mam_sandbox):
|
||||||
|
"""Verify that table structure and indexes are automatically created if DB is deleted."""
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
if db_path.exists():
|
||||||
|
db_path.unlink()
|
||||||
|
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = []
|
||||||
|
"""
|
||||||
|
res = run_mutation(mam_sandbox, mutation)
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
assert db_path.exists()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||||
|
tables = [row[0] for row in cursor.fetchall()]
|
||||||
|
assert "state" in tables
|
||||||
|
assert "sessions" in tables
|
||||||
|
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='index';")
|
||||||
|
indexes = [row[0] for row in cursor.fetchall()]
|
||||||
|
assert "idx_sessions_pane_cwd" in indexes
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_comp_monitor_monotonic_seq_updates(mam_sandbox):
|
||||||
|
"""Verify sequence monotonic updates in mqtt_common."""
|
||||||
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
||||||
|
|
||||||
|
# Setup job JSON in jobs registry dir
|
||||||
|
registry_dir = mam_sandbox / ".mam" / "jobs"
|
||||||
|
registry_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
job_id = "job-seq-123"
|
||||||
|
job_data = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"status": "running",
|
||||||
|
"last_seq": 0
|
||||||
|
}
|
||||||
|
with open(registry_dir / f"{job_id}.json", "w") as f:
|
||||||
|
json.dump(job_data, f)
|
||||||
|
|
||||||
|
# Get sequence multiple times
|
||||||
|
s1 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||||
|
s2 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||||
|
s3 = mqtt_common.next_seq(job_id, str(registry_dir))
|
||||||
|
|
||||||
|
assert s1 == 1
|
||||||
|
assert s2 == 2
|
||||||
|
assert s3 == 3
|
||||||
|
|
||||||
|
# Reload and check
|
||||||
|
with open(registry_dir / f"{job_id}.json", "r") as f:
|
||||||
|
loaded = json.load(f)
|
||||||
|
assert loaded["last_seq"] == 3
|
||||||
|
|
||||||
|
def test_comp_monitor_auto_state_recovery(mam_sandbox, mock_herdr):
|
||||||
|
"""Verify that reconcile.sh auto-terminates a session if herdr is confirmed dead."""
|
||||||
|
# Write a running session in YAML
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-autorecovery-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||||
|
run_mutation(mam_sandbox, mutation)
|
||||||
|
|
||||||
|
# Run reconcile.sh --once --emit-diff (which runs the dry-run, which does NOT write but reports)
|
||||||
|
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
drifts = data["drifts"]
|
||||||
|
# Should detect drift class A: herdr gone
|
||||||
|
assert any(dr["class"] == "A" and dr["name"] == "test-autorecovery-creator-claude" for dr in drifts)
|
||||||
|
|
||||||
|
# Now run reconcile.sh --once --emit-diff (without --dry-run) to trigger write
|
||||||
|
res_write = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||||
|
assert res_write.returncode == 0
|
||||||
|
|
||||||
|
# Verify session is marked terminated in the SQLite DB
|
||||||
|
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT status FROM sessions WHERE name='test-autorecovery-creator-claude'").fetchone()
|
||||||
|
assert row[0] == "terminated"
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
import shutil
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||||
|
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
||||||
|
"""
|
||||||
|
Tier 3: Create Session Integration
|
||||||
|
Covers pairwise combination: --onboard + --submit-job + --wrapper + HERDR_SERVER_NAME env override
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
|
||||||
|
# Set HERDR_SERVER_NAME env var to test environment override combination
|
||||||
|
monkeypatch.setenv("HERDR_SERVER_NAME", "custom_server")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"bash", str(script_path),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator",
|
||||||
|
"--onboard",
|
||||||
|
"--submit-job", "Test onboard prompt",
|
||||||
|
"--wrapper"
|
||||||
|
]
|
||||||
|
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
if res.returncode != 0:
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
debug_info = (
|
||||||
|
f"MOCK HERDR CALLS: {state.get('calls', [])}\n"
|
||||||
|
f"MOCK HERDR AGENTS: {state.get('agents', {})}\n"
|
||||||
|
)
|
||||||
|
assert False, f"Stdout: {res.stdout}\nStderr: {res.stderr}\nDebug:\n{debug_info}"
|
||||||
|
|
||||||
|
assert res.returncode == 0
|
||||||
|
|
||||||
|
# Verify it is registered in yaml
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
assert yaml_path.exists()
|
||||||
|
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
|
||||||
|
sessions = reg.get("herdr_sessions", [])
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session = sessions[0]
|
||||||
|
|
||||||
|
assert session["role"] == "Creator"
|
||||||
|
assert session["herdr_server"] == "custom_server"
|
||||||
|
assert session["delegate_job_id"] is not None
|
||||||
|
assert session["status"] == "running"
|
||||||
|
|
||||||
|
# Verify delegate job JSON file exists and contains correct info
|
||||||
|
job_id = session["delegate_job_id"]
|
||||||
|
job_file = tmp_path / ".mam" / "jobs" / f"{job_id}.json"
|
||||||
|
assert job_file.exists()
|
||||||
|
|
||||||
|
with open(job_file, 'r') as jf:
|
||||||
|
job_data = json.load(jf)
|
||||||
|
|
||||||
|
assert job_data["job_id"] == job_id
|
||||||
|
assert "Test onboard prompt" in job_data["prompt"]
|
||||||
|
assert job_data["agent_session"] == f"herdr:{session['name']}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Tier 3: Stop Session Integration
|
||||||
|
Covers pairwise combination: --purge-conversation + --yes + HOME override
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
|
||||||
|
# 1. Run create_session to register a session with isolation enabled
|
||||||
|
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
cmd_create = [
|
||||||
|
"bash", str(create_script),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator"
|
||||||
|
]
|
||||||
|
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_create.returncode == 0, f"Stdout: {res_create.stdout}\nStderr: {res_create.stderr}"
|
||||||
|
|
||||||
|
# Get session name and isolation uuid
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
session = reg["herdr_sessions"][0]
|
||||||
|
session_name = session["name"]
|
||||||
|
iso_uuid = session["isolation"]["uuid"]
|
||||||
|
iso_root = Path(session["isolation"]["root"])
|
||||||
|
|
||||||
|
# Ensure isolation home root folder is provisioned
|
||||||
|
assert iso_root.exists()
|
||||||
|
|
||||||
|
# Locate dynamically generated conversation file in claude projects
|
||||||
|
key = str(tmp_path).replace('/', '-').replace('_', '-')
|
||||||
|
proj_dir = iso_root / "projects" / key
|
||||||
|
assert proj_dir.exists(), f"Expected isolated projects directory {proj_dir} to exist"
|
||||||
|
|
||||||
|
jsonls = list(proj_dir.glob("*.jsonl"))
|
||||||
|
assert len(jsonls) == 1, f"Expected exactly 1 jsonl file in {proj_dir}, found {jsonls}"
|
||||||
|
jsonl_file = jsonls[0]
|
||||||
|
assert jsonl_file.exists()
|
||||||
|
|
||||||
|
# Update mock herdr's state to match the running agent so the stop kill-chain works gracefully
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
state["agents"][session_name] = {
|
||||||
|
"status": "running",
|
||||||
|
"agent": "claude",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"pid": 12345,
|
||||||
|
"pane_id": "w1:p1",
|
||||||
|
"command": "claude",
|
||||||
|
"buffer": "Anthropic Claude Ready"
|
||||||
|
}
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
# 2. Calling stop_session without --yes fails for --purge-conversation
|
||||||
|
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
cmd_stop_no_yes = [
|
||||||
|
"bash", str(stop_script),
|
||||||
|
"--session", session_name,
|
||||||
|
"--purge-conversation"
|
||||||
|
]
|
||||||
|
res_stop_no_yes = subprocess.run(cmd_stop_no_yes, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_stop_no_yes.returncode == 3
|
||||||
|
assert "DANGER: --purge-conversation" in res_stop_no_yes.stdout
|
||||||
|
|
||||||
|
# Ensure nothing was deleted yet
|
||||||
|
assert iso_root.exists()
|
||||||
|
assert jsonl_file.exists()
|
||||||
|
|
||||||
|
# 3. Run stop_session with --yes
|
||||||
|
cmd_stop_yes = [
|
||||||
|
"bash", str(stop_script),
|
||||||
|
"--session", session_name,
|
||||||
|
"--purge-conversation",
|
||||||
|
"--yes"
|
||||||
|
]
|
||||||
|
res_stop_yes = subprocess.run(cmd_stop_yes, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_stop_yes.returncode == 0, f"Stdout: {res_stop_yes.stdout}\nStderr: {res_stop_yes.stderr}"
|
||||||
|
|
||||||
|
# Assertions:
|
||||||
|
# - Isolation directory deleted
|
||||||
|
assert not iso_root.exists()
|
||||||
|
# - Conversation files deleted
|
||||||
|
assert not jsonl_file.exists()
|
||||||
|
# - Session completely removed from YAML/DB registry
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
assert not any(s["name"] == session_name for s in reg.get("herdr_sessions", []))
|
||||||
|
|
||||||
|
db_path = tmp_path / ".mam" / "agent-sessions.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM sessions WHERE name=?", (session_name,))
|
||||||
|
assert cursor.fetchone()[0] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_resume_fallbacks(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Tier 3: Resume Session Fallbacks
|
||||||
|
Covers boundary checks, invalid / missing UUIDs, and workspace disk scan fallback resolution.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
|
||||||
|
# 1. Calling resume_session.sh with missing arguments
|
||||||
|
resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||||
|
cmd_missing = ["bash", str(resume_script), "--session", "test-session"]
|
||||||
|
res_missing = subprocess.run(cmd_missing, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_missing.returncode == 2
|
||||||
|
assert "ERROR:" in res_missing.stderr
|
||||||
|
|
||||||
|
# 2. Calling with non-existent session
|
||||||
|
cmd_nonexistent = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", "non-existent"]
|
||||||
|
res_nonexistent = subprocess.run(cmd_nonexistent, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_nonexistent.returncode == 1
|
||||||
|
assert "ERROR: No saved session for" in res_nonexistent.stderr
|
||||||
|
|
||||||
|
# 3. Fallback resolution: set up a stopped session in YAML with NO own ID
|
||||||
|
session_name = "test-fallback-creator-claude"
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'test-fallback-creator-claude',
|
||||||
|
'status': 'stopped',
|
||||||
|
'role': 'Creator',
|
||||||
|
'pane': {'cwd': 'WS_PLACEHOLDER'}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||||
|
res_mut = run_mutation(tmp_path, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
# Seed a jsonl file representing a conversation under the workspace path
|
||||||
|
key = str(tmp_path).replace('/', '-').replace('_', '-')
|
||||||
|
proj_dir = tmp_path / ".claude" / "projects" / key
|
||||||
|
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
scanned_uuid = "scanned-uuid-xyz-123"
|
||||||
|
(proj_dir / f"{scanned_uuid}.jsonl").write_text(json.dumps({"sessionId": scanned_uuid}) + "\n")
|
||||||
|
|
||||||
|
# Run resume_session.sh
|
||||||
|
cmd_resume = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", session_name]
|
||||||
|
res_resume = subprocess.run(cmd_resume, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_resume.returncode == 0, f"Stdout: {res_resume.stdout}\nStderr: {res_resume.stderr}"
|
||||||
|
|
||||||
|
# Verify the session resumed using the resolved UUID
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
calls = state.get("calls", [])
|
||||||
|
# Find the agent start/new-session call
|
||||||
|
new_sess_call = None
|
||||||
|
for call in calls:
|
||||||
|
if "agent" in call and "start" in call and session_name in call:
|
||||||
|
new_sess_call = call
|
||||||
|
break
|
||||||
|
|
||||||
|
assert new_sess_call is not None, f"Calls: {calls}"
|
||||||
|
# Verify the argument contains the resolved scanned_uuid
|
||||||
|
assert any(scanned_uuid in arg for arg in new_sess_call), f"Resume arguments: {new_sess_call}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_reconcile_diff_formats(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Tier 3: Reconcile Drift Detection and Diff Formatting
|
||||||
|
Covers outputs of reconcile.sh --once --emit-diff under dry-run and actual mutation.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
|
||||||
|
# Seed a running session in YAML/DB that is NOT in herdr (Drift class A)
|
||||||
|
session_name = "drift-a-creator-claude"
|
||||||
|
mutation = """
|
||||||
|
d['herdr_sessions'] = [{
|
||||||
|
'name': 'drift-a-creator-claude',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'Creator',
|
||||||
|
'pane': {
|
||||||
|
'cwd': 'WS_PLACEHOLDER',
|
||||||
|
'pid': 9876,
|
||||||
|
'cmd': 'claude',
|
||||||
|
'cmd_full': 'claude'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||||
|
res_mut = run_mutation(tmp_path, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
db_path = tmp_path / ".mam" / "agent-sessions.db"
|
||||||
|
assert db_path.exists()
|
||||||
|
|
||||||
|
# Verify DB content
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
db_rows = conn.execute("SELECT * FROM sessions").fetchall()
|
||||||
|
db_state = conn.execute("SELECT * FROM state").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Verify YAML content
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
yaml_content = f.read()
|
||||||
|
|
||||||
|
# Verify load_state_json output
|
||||||
|
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||||
|
res_load = subprocess.run(["bash", "-c", f"source {lib_path} && load_state_json"], capture_output=True, text=True)
|
||||||
|
|
||||||
|
# Verify herdr ls -F output
|
||||||
|
res_ls = subprocess.run(["herdr", "ls", "-F", "#{session_name}|#{session_created}"], capture_output=True, text=True)
|
||||||
|
|
||||||
|
# 1. Run reconcile with --dry-run
|
||||||
|
cmd_dry = ["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"]
|
||||||
|
res_dry = subprocess.run(cmd_dry, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_dry.returncode == 0, f"Stderr: {res_dry.stderr}"
|
||||||
|
|
||||||
|
# Verify stdout is valid JSON and reports class A drift
|
||||||
|
try:
|
||||||
|
data_dry = json.loads(res_dry.stdout)
|
||||||
|
except Exception as e:
|
||||||
|
assert False, f"Failed to parse JSON. stdout: {res_dry.stdout}, stderr: {res_dry.stderr}, error: {e}"
|
||||||
|
|
||||||
|
assert "drifts" in data_dry, f"JSON: {data_dry}"
|
||||||
|
drifts_dry = data_dry["drifts"]
|
||||||
|
|
||||||
|
# Debug print on failure
|
||||||
|
if not any(d["class"] == "A" and d["name"] == session_name for d in drifts_dry):
|
||||||
|
debug_info = (
|
||||||
|
f"DB_ROWS: {db_rows}\n"
|
||||||
|
f"DB_STATE: {db_state}\n"
|
||||||
|
f"YAML: {yaml_content}\n"
|
||||||
|
f"LOAD_STATE_JSON STDOUT: {res_load.stdout}\n"
|
||||||
|
f"LOAD_STATE_JSON STDERR: {res_load.stderr}\n"
|
||||||
|
f"HERDR LS STDOUT: {res_ls.stdout}\n"
|
||||||
|
f"HERDR LS STDERR: {res_ls.stderr}\n"
|
||||||
|
f"AGENT_SESSIONS_YAML ENV: {os.environ.get('AGENT_SESSIONS_YAML')}\n"
|
||||||
|
)
|
||||||
|
# Execute Python sub-process trace
|
||||||
|
py_code = """
|
||||||
|
import os, json, glob, subprocess, time, sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
yaml_path = os.environ['YAML_PATH']
|
||||||
|
home = os.environ['HOME_DIR']
|
||||||
|
|
||||||
|
d = {}
|
||||||
|
ws_root = os.environ.get('WORKSPACE_ROOT')
|
||||||
|
print("WORKSPACE_ROOT:", ws_root)
|
||||||
|
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
|
||||||
|
out = subprocess.check_output(['bash', '-c', script])
|
||||||
|
print("LOAD STATE JSON OUT:", out.decode('utf-8'))
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"YAML_PATH": str(yaml_path),
|
||||||
|
"HOME_DIR": str(tmp_path),
|
||||||
|
"CLAUDE_PROJECT_DIR": str(tmp_path / ".claude" / "projects"),
|
||||||
|
"LOCAL_BIN": str(tmp_path / ".local" / "bin"),
|
||||||
|
"WORKSPACE_ROOT": str(tmp_path),
|
||||||
|
"AGENT_SESSIONS_YAML": str(yaml_path),
|
||||||
|
"PATH": os.environ.get("PATH", "")
|
||||||
|
}
|
||||||
|
res_test = subprocess.run(["python3", "-c", py_code], env=env, capture_output=True, text=True)
|
||||||
|
debug_info += f"TEST_CODE_STDOUT: {res_test.stdout}\nTEST_CODE_STDERR: {res_test.stderr}\n"
|
||||||
|
assert False, f"Drift class A not found in drifts. Output JSON: {json.dumps(data_dry, indent=2)}\nStderr: {res_dry.stderr}\nDebug:\n{debug_info}"
|
||||||
|
|
||||||
|
# Verify dry-run made no state changes in SQLite
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone()
|
||||||
|
assert row[0] == "running"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 2. Run reconcile without --dry-run
|
||||||
|
cmd_real = ["bash", str(reconcile_script), "--once", "--emit-diff"]
|
||||||
|
res_real = subprocess.run(cmd_real, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_real.returncode == 0, f"Stderr: {res_real.stderr}"
|
||||||
|
|
||||||
|
# Verify DB has been updated to terminated
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone()
|
||||||
|
assert row[0] == "terminated"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Tier 3: Invalid Arguments & Exit Status Checks
|
||||||
|
Verifies that all scripts correctly handle invalid options and combinations with correct exit statuses.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
|
||||||
|
# 1. create_session.sh with missing arguments
|
||||||
|
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(create_script)], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --workspace required" in res.stderr
|
||||||
|
|
||||||
|
res = subprocess.run(["bash", str(create_script), "--workspace", str(tmp_path)], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --agent required" in res.stderr
|
||||||
|
|
||||||
|
# 2. stop_session.sh with missing arguments
|
||||||
|
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
res = subprocess.run(["bash", str(stop_script)], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: --session required" in res.stderr
|
||||||
|
|
||||||
|
# 3. stop_session.sh cannot infer agent from weird session name
|
||||||
|
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: cannot infer agent" in res.stderr
|
||||||
|
|
||||||
|
# 4. stop_session.sh on non-existent session
|
||||||
|
res = subprocess.run(["bash", str(stop_script), "--session", "nonexistent-creator-claude"], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 1
|
||||||
|
assert "ERROR: session 'nonexistent-creator-claude' not in" in res.stderr
|
||||||
|
|
||||||
|
# 5. reconcile.sh with invalid flag
|
||||||
|
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
res = subprocess.run(["bash", str(reconcile_script), "--invalid-flag"], capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res.returncode == 2
|
||||||
|
assert "ERROR: unknown arg" in res.stderr
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
import shutil
|
||||||
|
import yaml
|
||||||
|
import time
|
||||||
|
import concurrent.futures
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
|
||||||
|
def run_mutation(mam_sandbox, mutation_str, env=None):
|
||||||
|
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||||
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
||||||
|
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
if env:
|
||||||
|
run_env.update(env)
|
||||||
|
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_scenario1_standard_lifecycle(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Scenario 1: Standard Agent Session Lifecycle
|
||||||
|
Spawn session, check registry status, query status via status.sh, and stop session.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
status_script = tmp_path / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
||||||
|
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||||
|
|
||||||
|
session_name = "e2e-sess1-creator-claude"
|
||||||
|
|
||||||
|
# 1. Spawn session using create_session.sh
|
||||||
|
cmd_create = [
|
||||||
|
"bash", str(create_script),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator",
|
||||||
|
"--session", session_name
|
||||||
|
]
|
||||||
|
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_create.returncode == 0, f"Stderr: {res_create.stderr}"
|
||||||
|
|
||||||
|
# Verify mock herdr registers it
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
assert session_name in state["agents"]
|
||||||
|
assert state["agents"][session_name]["status"] == "running"
|
||||||
|
|
||||||
|
# 2. Check status via status.sh --json
|
||||||
|
cmd_status = ["bash", str(status_script), "--json"]
|
||||||
|
res_status = subprocess.run(cmd_status, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_status.returncode == 0
|
||||||
|
|
||||||
|
status_data = json.loads(res_status.stdout)
|
||||||
|
sessions_detail = status_data["sessions_detail"]
|
||||||
|
assert any(s["name"] == session_name and s["status"] == "running" for s in sessions_detail)
|
||||||
|
|
||||||
|
# 3. Stop it via stop_session.sh
|
||||||
|
cmd_stop = ["bash", str(stop_script), "--session", session_name]
|
||||||
|
res_stop = subprocess.run(cmd_stop, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_stop.returncode == 0
|
||||||
|
|
||||||
|
# Verify status in YAML/DB becomes stopped
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
session_entry = [s for s in reg.get("herdr_sessions", []) if s["name"] == session_name][0]
|
||||||
|
assert session_entry["status"] == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_scenario2_disconnect_resume(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Scenario 2: Session Disconnect and Resume
|
||||||
|
Spawn session, simulate process death in herdr state, run resume_session.sh, and assert resume UUID.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
|
||||||
|
|
||||||
|
session_name = "e2e-sess2-creator-claude"
|
||||||
|
|
||||||
|
# 1. Spawn session
|
||||||
|
cmd_create = [
|
||||||
|
"bash", str(create_script),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator",
|
||||||
|
"--session", session_name
|
||||||
|
]
|
||||||
|
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_create.returncode == 0
|
||||||
|
|
||||||
|
# Get own UUID from YAML
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
orig_session = reg["herdr_sessions"][0]
|
||||||
|
# Simulate first message creating own ID (materialized own ID)
|
||||||
|
own_uuid = "e2e-own-uuid-111"
|
||||||
|
|
||||||
|
iso_root = Path(orig_session["isolation"]["root"])
|
||||||
|
key = str(tmp_path).replace('/', '-').replace('_', '-')
|
||||||
|
proj_dir = iso_root / "projects" / key
|
||||||
|
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(proj_dir / f"{own_uuid}.jsonl").write_text(json.dumps({"sessionId": own_uuid}) + "\n")
|
||||||
|
|
||||||
|
mutation = f"""
|
||||||
|
for s in d.get('herdr_sessions', []):
|
||||||
|
if s.get('name') == '{session_name}':
|
||||||
|
s['status'] = 'stopped'
|
||||||
|
s['claude_session_id_own'] = '{own_uuid}'
|
||||||
|
"""
|
||||||
|
res_mut = run_mutation(tmp_path, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
# 2. Simulate process death in mock herdr state
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
if session_name in state["agents"]:
|
||||||
|
del state["agents"][session_name]
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
# Clear herdr calls to isolate assertions
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
state["calls"] = []
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
# 3. Run resume_session.sh
|
||||||
|
cmd_resume = [
|
||||||
|
"bash", str(resume_script),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--session", session_name
|
||||||
|
]
|
||||||
|
res_resume = subprocess.run(cmd_resume, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_resume.returncode == 0, f"Stderr: {res_resume.stderr}"
|
||||||
|
|
||||||
|
# 4. Assert that it resumes using the correct UUID in the start command arguments
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
|
||||||
|
calls = state.get("calls", [])
|
||||||
|
resume_call = None
|
||||||
|
for call in calls:
|
||||||
|
if "agent" in call and "start" in call and session_name in call:
|
||||||
|
resume_call = call
|
||||||
|
break
|
||||||
|
|
||||||
|
assert resume_call is not None, f"Could not find resume agent start call in calls: {calls}"
|
||||||
|
assert any(own_uuid in arg for arg in resume_call), f"Expected UUID {own_uuid} to be in resume command: {resume_call}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_scenario3_drift_auto_reconciliation(mam_sandbox, mock_herdr):
|
||||||
|
"""
|
||||||
|
Scenario 3: Drift Detection and Auto-Reconciliation
|
||||||
|
Setup drift states (running in herdr but not in YAML registry, and running in YAML registry but terminated in herdr).
|
||||||
|
Verify reconcile.sh automatically reconciles both.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
||||||
|
|
||||||
|
# Drift 1: Running in herdr but not in YAML
|
||||||
|
drift_herdr_only = "drift-herdr-only-creator-claude"
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
state = json.load(f)
|
||||||
|
state["agents"][drift_herdr_only] = {
|
||||||
|
"status": "running",
|
||||||
|
"agent": "claude",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"pid": 5555,
|
||||||
|
"pane_id": "w1:p1",
|
||||||
|
"command": "claude",
|
||||||
|
"buffer": "Anthropic Claude Ready"
|
||||||
|
}
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
# Drift 2: Running in YAML registry but terminated in herdr
|
||||||
|
drift_yaml_only = "drift-yaml-only-creator-claude"
|
||||||
|
mutation = f"""
|
||||||
|
d['herdr_sessions'] = [{{
|
||||||
|
'name': '{drift_yaml_only}',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'Creator',
|
||||||
|
'pane': {{
|
||||||
|
'cwd': 'WS_PLACEHOLDER',
|
||||||
|
'pid': 6666,
|
||||||
|
'cmd': 'claude',
|
||||||
|
'cmd_full': 'claude'
|
||||||
|
}}
|
||||||
|
}}]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||||
|
res_mut = run_mutation(tmp_path, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
# Run reconcile.sh --once
|
||||||
|
cmd_reconcile = ["bash", str(reconcile_script), "--once"]
|
||||||
|
res_recon = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
assert res_recon.returncode == 0, f"Stderr: {res_recon.stderr}"
|
||||||
|
|
||||||
|
# Verify YAML/DB states
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
sessions = reg.get("herdr_sessions", [])
|
||||||
|
|
||||||
|
# drift-herdr-only-creator-claude should have been auto-registered as running
|
||||||
|
sess_herdr_only = [s for s in sessions if s["name"] == drift_herdr_only]
|
||||||
|
assert len(sess_herdr_only) == 1
|
||||||
|
assert sess_herdr_only[0]["status"] == "running"
|
||||||
|
|
||||||
|
# drift-yaml-only-creator-claude should have been auto-terminated
|
||||||
|
sess_yaml_only = [s for s in sessions if s["name"] == drift_yaml_only]
|
||||||
|
assert len(sess_yaml_only) == 1
|
||||||
|
assert sess_yaml_only[0]["status"] == "terminated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_scenario4_parallel_flock_locking(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Scenario 4: Parallel Session Operations with flock Locking
|
||||||
|
Run multiple concurrent session creation scripts to verify SQLite locking prevents database corruption.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
||||||
|
|
||||||
|
num_sessions = 6
|
||||||
|
|
||||||
|
def run_create(i):
|
||||||
|
session_name = f"parallel-sess-{i}-creator-claude"
|
||||||
|
cmd = [
|
||||||
|
"bash", str(create_script),
|
||||||
|
"--workspace", str(tmp_path),
|
||||||
|
"--agent", "claude",
|
||||||
|
"--role", "Creator",
|
||||||
|
"--session", session_name
|
||||||
|
]
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Execute in parallel
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=num_sessions) as executor:
|
||||||
|
futures = [executor.submit(run_create, i) for i in range(num_sessions)]
|
||||||
|
results = [f.result() for f in futures]
|
||||||
|
|
||||||
|
# Verify that all succeeded
|
||||||
|
for i, res in enumerate(results):
|
||||||
|
assert res.returncode == 0, f"Session {i} failed. Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||||
|
|
||||||
|
# Verify all 6 sessions are present in YAML registry
|
||||||
|
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
|
||||||
|
with open(yaml_path, 'r') as f:
|
||||||
|
reg = yaml.safe_load(f)
|
||||||
|
sessions = reg.get("herdr_sessions", [])
|
||||||
|
|
||||||
|
registered_names = {s["name"] for s in sessions}
|
||||||
|
for i in range(num_sessions):
|
||||||
|
assert f"parallel-sess-{i}-creator-claude" in registered_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_e2e_scenario5_multi_agent_review_loop(mam_sandbox, mock_herdr, mock_agents):
|
||||||
|
"""
|
||||||
|
Scenario 5: Multi-Agent Review Loop
|
||||||
|
Run run_loop.sh under simulated conditions where reviewer output is mocked.
|
||||||
|
Verify loop terminates correctly with expected PASS and NOT PASS verdicts.
|
||||||
|
"""
|
||||||
|
tmp_path = mam_sandbox
|
||||||
|
loop_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||||
|
|
||||||
|
# 1. Seed sessions in registry for the worker, reviewer, and planner
|
||||||
|
worker_name = "test-worker-creator-claude"
|
||||||
|
reviewer_name = "test-reviewer-creator-claude"
|
||||||
|
planner_name = "test-planner-creator-claude"
|
||||||
|
|
||||||
|
mutation = f"""
|
||||||
|
d['herdr_sessions'] = [
|
||||||
|
{{
|
||||||
|
'name': '{worker_name}',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'worker',
|
||||||
|
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
'name': '{reviewer_name}',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'reviewer',
|
||||||
|
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
'name': '{planner_name}',
|
||||||
|
'status': 'running',
|
||||||
|
'role': 'planner',
|
||||||
|
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
""".replace("WS_PLACEHOLDER", str(tmp_path))
|
||||||
|
res_mut = run_mutation(tmp_path, mutation)
|
||||||
|
assert res_mut.returncode == 0
|
||||||
|
|
||||||
|
# Seed mock herdr state with these running sessions to satisfy has-session checks
|
||||||
|
with open(mock_herdr, 'r') as f:
|
||||||
|
herdr_state = json.load(f)
|
||||||
|
for name in [worker_name, reviewer_name, planner_name]:
|
||||||
|
herdr_state["agents"][name] = {
|
||||||
|
"status": "running",
|
||||||
|
"agent": "claude",
|
||||||
|
"cwd": str(tmp_path),
|
||||||
|
"pid": 9999,
|
||||||
|
"pane_id": "w1:p1",
|
||||||
|
"command": "claude",
|
||||||
|
"buffer": "Anthropic Claude Ready"
|
||||||
|
}
|
||||||
|
with open(mock_herdr, 'w') as f:
|
||||||
|
json.dump(herdr_state, f, indent=2)
|
||||||
|
|
||||||
|
# Define mock reviewer and planner outputs
|
||||||
|
# Let's mock a scenario:
|
||||||
|
# Critique: worker challenge
|
||||||
|
# Refinement: refined plan
|
||||||
|
# Review: First try NOT PASS, Second try PASS
|
||||||
|
job_responses = {
|
||||||
|
"Planner": "Refined Plan:\n1. Implement X\n2. Verify X",
|
||||||
|
"critique": "Creator Critique: Plan has 1 edge case.",
|
||||||
|
"Worker": "Creator Output: Code updated.",
|
||||||
|
"Reviewer": "Reviewer verdict:\n\n[VERDICT: PASS]" # will override inside simulator to test iteration logic
|
||||||
|
}
|
||||||
|
|
||||||
|
# We will run a background simulator thread to resolve jobs
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
def simulate_delegate_jobs():
|
||||||
|
jobs_dir = tmp_path / ".mam" / "jobs"
|
||||||
|
iteration = 1
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
if not jobs_dir.exists():
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for job_file in jobs_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(job_file, 'r+') as f:
|
||||||
|
job = json.load(f)
|
||||||
|
if job.get("status") == "pending":
|
||||||
|
job_id = job["job_id"]
|
||||||
|
role = job.get("role", "Worker")
|
||||||
|
prompt = job.get("prompt", "")
|
||||||
|
|
||||||
|
# Determine response text
|
||||||
|
response_text = ""
|
||||||
|
if role == "Planner":
|
||||||
|
response_text = job_responses["Planner"]
|
||||||
|
elif "Challenge" in prompt or "Critique" in prompt:
|
||||||
|
response_text = job_responses["critique"]
|
||||||
|
elif role == "Worker":
|
||||||
|
response_text = job_responses["Worker"]
|
||||||
|
elif role == "Reviewer":
|
||||||
|
# For Reviewer, fail the first time, pass the second time
|
||||||
|
if iteration == 1:
|
||||||
|
response_text = "Review report:\nSome lint issues found.\n\n[VERDICT: NOT PASS]"
|
||||||
|
iteration += 1
|
||||||
|
else:
|
||||||
|
response_text = "Review report:\nAll clean.\n\n[VERDICT: PASS]"
|
||||||
|
|
||||||
|
# Write final report
|
||||||
|
job_work_dir = jobs_dir / job_id
|
||||||
|
job_work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(job_work_dir / "report-final.md").write_text(response_text)
|
||||||
|
|
||||||
|
# Complete job
|
||||||
|
job["status"] = "completed"
|
||||||
|
f.seek(0)
|
||||||
|
json.dump(job, f, indent=2)
|
||||||
|
f.truncate()
|
||||||
|
|
||||||
|
# Publish completed event over MQTT using publish_event.py
|
||||||
|
pub_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
|
||||||
|
cmd_pub = [
|
||||||
|
sys.executable, str(pub_script),
|
||||||
|
"--registry-dir", str(jobs_dir),
|
||||||
|
"--job", job_id,
|
||||||
|
"--event", "completed",
|
||||||
|
"--detail", f"{role} finished work"
|
||||||
|
]
|
||||||
|
subprocess.run(cmd_pub, capture_output=True, text=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
sim_thread = threading.Thread(target=simulate_delegate_jobs)
|
||||||
|
sim_thread.daemon = True
|
||||||
|
sim_thread.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run run_loop.sh
|
||||||
|
cmd_loop = [
|
||||||
|
"bash", str(loop_script),
|
||||||
|
"--target-agent", worker_name,
|
||||||
|
"--reviewer", reviewer_name,
|
||||||
|
"--plan",
|
||||||
|
"--plan-talk", "1",
|
||||||
|
"--max-loop", "3",
|
||||||
|
"--task", "Implement feature X and verify"
|
||||||
|
]
|
||||||
|
import sys
|
||||||
|
run_env = dict(os.environ)
|
||||||
|
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||||
|
res_loop = subprocess.run(cmd_loop, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
|
||||||
|
# Verify that it succeeded and executed the corrective loop
|
||||||
|
assert res_loop.returncode == 0, f"Loop failed. Stdout: {res_loop.stdout}\nStderr: {res_loop.stderr}"
|
||||||
|
assert "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stdout or "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stderr or "NOT PASS" in res_loop.stdout
|
||||||
|
assert "Reviewer 'test-reviewer-creator-claude': PASS" in res_loop.stdout
|
||||||
|
assert "Mux loop finished with 100% PASS verdicts." in res_loop.stdout
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
sim_thread.join(timeout=1.0)
|
||||||
Reference in New Issue
Block a user