Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts

This commit is contained in:
2026-07-19 20:05:48 +09:00
parent 30e606b0fa
commit ff7a2873f9
29 changed files with 3099 additions and 95 deletions
+121 -21
View File
@@ -28,7 +28,7 @@ AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
export PATH="$dir:$PATH"
export PATH="$PATH:$dir"
fi
done
@@ -58,7 +58,16 @@ _resolve_real_herdr_path() {
_init_herdr_isolation() {
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$wrapper_dir"
cat <<'EOF' > "$wrapper_dir/herdr"
if [ -x "$wrapper_dir/herdr" ]; then
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
return 0
fi
local tmp_file
tmp_file=$(mktemp "$wrapper_dir/herdr.XXXXXX")
cat <<'EOF' > "$tmp_file"
#!/usr/bin/env bash
# Herdr-to-Herdr translation shim wrapper
set -euo pipefail
@@ -81,6 +90,16 @@ _resolve_real_herdr() {
}
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
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cmd="${1:-}"
if [ -z "$cmd" ]; then
@@ -89,12 +108,20 @@ if [ -z "$cmd" ]; then
fi
shift
case "$cmd" in
has-session)
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) shift ;;
esac
done
@@ -104,12 +131,29 @@ case "$cmd" in
name="" ws="" run_cmd=""
while [ $# -gt 0 ]; do
case "$1" in
-s) name="$2"; shift 2 ;;
-c) ws="$2"; shift 2 ;;
-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
"$REAL_HERDR" workspace create --label "${HERDR_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true
parsed=$(python3 -c "
@@ -136,13 +180,14 @@ print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
ws_label="${HERDR_SERVER_NAME:-default}"
ws_id="w1"
if [ "$ws_label" != "default" ]; then
ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | python3 -c "
import sys, json
ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | WS_LABEL="$ws_label" python3 -c "
import sys, os, json
try:
ws_label = os.environ.get('WS_LABEL', '')
data = json.loads(sys.stdin.read())
res = data.get('result', data)
for ws in res.get('workspaces', []):
if ws.get('label') == '$ws_label' or ws.get('workspace_id') == '$ws_label':
if ws.get('label') == ws_label or ws.get('workspace_id') == ws_label:
print(ws.get('workspace_id'))
sys.exit(0)
except Exception:
@@ -157,7 +202,14 @@ except Exception:
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) shift ;;
esac
done
@@ -168,8 +220,22 @@ except Exception:
sess="" format=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-F) format="$2"; shift 2 ;;
-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
@@ -198,7 +264,14 @@ except Exception:
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) shift ;;
esac
done
@@ -208,7 +281,14 @@ except Exception:
sess="" key=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) key="$1"; shift ;;
esac
done
@@ -221,7 +301,14 @@ except Exception:
text=""
while [ $# -gt 0 ]; do
case "$1" in
-b) buf="$2"; shift 2 ;;
-b)
if [ $# -lt 2 ]; then
echo "Error: -b requires a value" >&2
exit 1
fi
buf="$2"
shift 2
;;
*) text="$1"; shift ;;
esac
done
@@ -231,7 +318,14 @@ except Exception:
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-t) sess="$2"; shift 2 ;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) shift ;;
esac
done
@@ -249,7 +343,11 @@ try:
data = json.load(sys.stdin)
res = data.get('result', data)
for a in res.get('agents', []):
print(f\"{a['name']}|0\")
try:
name = a.get('name') or a.get('agent') or 'unknown'
print(f\"{name}|0\")
except Exception:
pass
except Exception:
pass
" || true
@@ -259,7 +357,8 @@ except Exception:
;;
esac
EOF
chmod +x "$wrapper_dir/herdr"
chmod +x "$tmp_file"
mv -f "$tmp_file" "$wrapper_dir/herdr"
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
@@ -334,7 +433,7 @@ name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('herdr_workspace', 'default'))
print(s.get('herdr_workspace') or s.get('herdr_server') or 'default')
sys.exit(0)
print(os.environ.get('HERDR_SERVER_NAME', 'default'))
")
@@ -342,13 +441,14 @@ print(os.environ.get('HERDR_SERVER_NAME', 'default'))
echo "w1"
else
local ws_id
ws_id=$(herdr workspace list 2>/dev/null | python3 -c "
import sys, json
ws_id=$(herdr workspace list 2>/dev/null | RAW_LABEL="$raw_label" python3 -c "
import sys, os, json
try:
raw_label = os.environ.get('RAW_LABEL', '')
data = json.loads(sys.stdin.read())
res = data.get('result', data)
for ws in res.get('workspaces', []):
if ws.get('label') == '$raw_label' or ws.get('workspace_id') == '$raw_label':
if ws.get('label') == raw_label or ws.get('workspace_id') == raw_label:
print(ws.get('workspace_id'))
sys.exit(0)
except Exception:
@@ -11,7 +11,7 @@
#
# 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
# 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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -28,6 +28,8 @@ pick_python() {
local py_bin
if [[ -n "${DELEGATE_JOB_PYTHON:-}" ]]; then
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
py_bin="${WORKDIR}/.venv/bin/python"
elif [[ -x ".venv/bin/python" ]]; then
@@ -56,7 +58,7 @@ multi-agent-mux-delegate-job <command> [options]
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
[--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>]
list [--registry-dir <dir>]
verify --job <id> --validate <script> [--registry-dir <dir>]
@@ -66,10 +68,10 @@ EOF
}
# ---- 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
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"
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
STRICT_ROLE_CHECK=0
@@ -153,8 +155,15 @@ EOF
break
fi
else
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
exit 1
wait "$sub_pid" 2>/dev/null
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
sleep 0.2
done
@@ -224,7 +233,7 @@ EOF
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
local job_dir="$REGISTRY_DIR/$JOB_ID"
local clean_session="${current_session#tmux:}"
local clean_session="${current_session#herdr:}"
if [[ "$DRY_RUN" != "1" ]]; then
mkdir -p "$job_dir"
cat <<EOF > "$job_dir/brief.md"
@@ -267,8 +276,15 @@ EOF
break
fi
else
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
exit 1
wait "$sub_pid" 2>/dev/null
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
sleep 0.2
done
@@ -387,38 +403,38 @@ run_agent() {
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
# one-shot print mode, because:
# - 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
# (you can't tell what happened if the agent crashes mid-turn).
# - the job registry already gives us an authoritative completion signal,
# 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`
# keeps the pane open after the agent exits so the user can review.
if [ "$AGENT" = "human" ]; then
echo "[human agent] complete the task, then run publish_event.py --event completed"
return
fi
local sess="${target_session#tmux:}"
local sess="${target_session#herdr:}"
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 "----"
return
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "ERROR: this skill requires tmux (interactive agent sessions)." >&2
echo " Install with: brew install tmux (or your package manager)" >&2
if ! command -v herdr >/dev/null 2>&1; then
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
echo " Ensure herdr is installed and executable." >&2
return 1
fi
local _tmux="tmux"
if [ -n "${TMUX_SERVER_NAME:-}" ]; then
_tmux="tmux -L $TMUX_SERVER_NAME"
local _herdr="herdr"
if [ -n "${HERDR_SERVER_NAME:-}" ]; then
_herdr="herdr -L $HERDR_SERVER_NAME"
fi
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 " 팁: 'multi-agent-mux-resume' 또는 'multi-agent-mux-create'를 통해 에이전트를 먼저 생성할 수 있습니다." >&2
return 1
@@ -431,7 +447,7 @@ run_agent() {
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
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:
print(s.get('role', ''))
break
@@ -483,7 +499,7 @@ else:
return 1
fi
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_tmux attach -t $sess)"
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_herdr session attach $sess)"
trap - EXIT
}
@@ -153,7 +153,11 @@ def main(argv=None) -> int:
expected_ids: Set[str] = {j["job_id"] 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)
# Resolve timeouts from CLI, falling back to the (first) job's settings.
@@ -159,7 +159,7 @@ import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
target_agent = os.environ.get('TARGET_AGENT')
reviewers = [s.get('name') for s in d.get('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))
"
}
@@ -199,7 +199,7 @@ import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
planner = ''
for s in d.get('herdr_sessions', []):
if 'planner' in s.get('role', ''):
if 'planner' in s.get('role', '').lower():
planner = s.get('name')
break
print(planner)
@@ -14,7 +14,9 @@
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
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
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
@@ -119,7 +121,7 @@ _changed = False
for s in d.get('herdr_sessions', []):
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
_name = s.get('name')
_srv = s.get('herdr_server') or 'default'
_srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
if _event == 'completed':
s['delegate_job_id'] = None
print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
@@ -316,10 +318,13 @@ except NameError:
import subprocess
d = {}
try:
ws_root = os.environ.get('WORKSPACE_ROOT')
if not ws_root:
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
lib_sh = os.environ.get('LIB_SH')
if not lib_sh:
ws_root = os.environ.get('WORKSPACE_ROOT')
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)
d = json.loads(out.decode('utf-8'))
except Exception:
@@ -337,7 +342,7 @@ unique_servers = {'default'}
if 'HERDR_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
for s in d.get('herdr_sessions', []):
srv = s.get('herdr_server') or 'default'
srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
unique_servers.add(srv)
try:
@@ -391,7 +396,7 @@ if herdr_confirmed:
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
if s.get('status') in ('terminated', 'archived', 'stopped'):
continue
srv = s.get('herdr_server') or 'default'
srv = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
if (name, srv) not in alive_set:
s['status'] = 'terminated'
s['terminated_at'] = now_iso
@@ -643,7 +648,7 @@ if not actions:
PYEOF
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
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
@@ -37,7 +37,8 @@ if [ -z "$UUID" ]; then
exit 1
fi
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
export HERDR_SERVER_NAME
# 2. If herdr is alive, print warning or attach.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
@@ -33,7 +33,8 @@ done
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
export HERDR_SERVER_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
if [ -z "$AGENT" ]; then
@@ -93,7 +93,7 @@ def get_job_status(s):
sessions_detail = []
for s in d.get('herdr_sessions', []):
name = s.get('name', '?')
server = s.get('herdr_server') or 'default'
server = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
jid, jstatus = get_job_status(s)
pane = s.get('pane') or {}
sessions_detail.append({
@@ -197,7 +197,7 @@ if not sessions:
print("(no sessions registered)")
for s in sessions:
name = s.get('name', '?')
server = s.get('herdr_server') or 'default'
server = s.get('herdr_workspace') or s.get('herdr_server') or 'default'
status = s.get('status', '?')
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
cmd = (s.get('pane') or {}).get('cmd', '?')
@@ -168,7 +168,7 @@ class _Header extends StatelessWidget {
runSpacing: 8,
children: [
_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()),
_Pill(text: 'SERVER: ${s.server}'),
],
@@ -47,7 +47,7 @@ class SessionTable extends StatelessWidget {
DataColumn2(label: Text('NAME'), size: ColumnSize.L),
DataColumn2(label: Text('SERVER'), 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('RESUME'), 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(_TmuxChip(alive: s.tmuxAlive)),
DataCell(_HerdrChip(alive: s.herdrAlive)),
DataCell(
Text(
s.pane.cmd ?? '?',
@@ -150,9 +150,9 @@ class _StatusChip extends StatelessWidget {
}
}
class _TmuxChip extends StatelessWidget {
class _HerdrChip extends StatelessWidget {
final bool alive;
const _TmuxChip({required this.alive});
const _HerdrChip({required this.alive});
@override
Widget build(BuildContext context) {
@@ -40,8 +40,8 @@ class _TerminalPaneState extends State<TerminalPane> {
Future<void> _startPty() async {
try {
final pty = await PtySession.start(
'tmux',
['-L', widget.serverName, 'attach', '-t', widget.sessionName],
'herdr',
['-L', widget.serverName, 'session', 'attach', widget.sessionName],
);
if (!mounted) {
pty.close();
@@ -15,7 +15,7 @@ void main() {
name: 'demo-session',
server: 'default',
status: 'running',
tmuxAlive: true,
herdrAlive: true,
pane: const Pane(
pid: 123,
cwd: '/x',
@@ -26,7 +26,7 @@ void main() {
resumeState: 'yes',
jobId: '-',
jobStatus: '-',
attachCommand: 'tmux attach -t demo-session',
attachCommand: 'herdr session attach demo-session',
driftClasses: const ['B'],
),
];
@@ -1,6 +1,6 @@
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
class Pane {
final int? pid;
@@ -13,7 +13,7 @@ class SessionRow {
final String name;
final String server;
final String status;
final bool tmuxAlive;
final bool herdrAlive;
final Pane pane;
final String? role;
final String resumeState;
@@ -28,7 +28,7 @@ class SessionRow {
required this.name,
required this.server,
required this.status,
required this.tmuxAlive,
required this.herdrAlive,
required this.pane,
this.role,
required this.resumeState,
@@ -49,7 +49,7 @@ class SessionRow {
name: json['name'] as String? ?? '?',
server: json['server'] as String? ?? 'default',
status: json['status'] as String? ?? '?',
tmuxAlive: json['tmux_alive'] as bool? ?? false,
herdrAlive: json['herdr_alive'] as bool? ?? false,
pane: Pane.fromSessionJson(json),
role: json['role'] as String?,
resumeState: json['resume_state'] as String? ?? '?',
@@ -64,3 +64,4 @@ class SessionRow {
);
}
}
@@ -4,14 +4,14 @@ import 'drift_entry.dart';
import 'session_row.dart';
/// 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].
@immutable
class SessionsSnapshot {
final DateTime timestamp;
final String yamlPath;
final List<String> tmuxSessionsAlive;
final bool tmuxConfirmed;
final List<String> herdrSessionsAlive;
final bool herdrConfirmed;
final List<DriftEntry> drifts;
final List<String> actions;
final List<SessionRow> sessions;
@@ -19,8 +19,8 @@ class SessionsSnapshot {
const SessionsSnapshot({
required this.timestamp,
required this.yamlPath,
required this.tmuxSessionsAlive,
required this.tmuxConfirmed,
required this.herdrSessionsAlive,
required this.herdrConfirmed,
required this.drifts,
required this.actions,
required this.sessions,
@@ -32,11 +32,11 @@ class SessionsSnapshot {
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
DateTime.now().toUtc(),
yamlPath: json['yaml_path'] as String? ?? '',
tmuxSessionsAlive:
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
herdrSessionsAlive:
(json['herdr_sessions_alive'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
herdrConfirmed: json['herdr_confirmed'] as bool? ?? false,
drifts: (json['drifts'] as List<dynamic>? ?? const [])
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
@@ -49,3 +49,4 @@ class SessionsSnapshot {
);
}
}
@@ -37,9 +37,9 @@ class SessionService {
throw StatusFetchException('preflight failed: bash is missing or not executable');
}
final tmuxResult = await runCommand(['tmux', '-V'], timeout: timeout);
if (tmuxResult.rc != 0) {
throw StatusFetchException('preflight failed: tmux is missing or not executable');
final herdrResult = await runCommand(['herdr', '-V'], timeout: timeout);
if (herdrResult.rc != 0) {
throw StatusFetchException('preflight failed: herdr is missing or not executable');
}
_preflightChecked = true;
@@ -24,18 +24,18 @@ void main() {
{
"timestamp": "2026-07-16T11:56:54Z",
"yaml_path": "/x/.mam/agent-sessions.yaml",
"tmux_sessions_alive": ["a|default"],
"tmux_confirmed": true,
"herdr_sessions_alive": ["a|default"],
"herdr_confirmed": true,
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
"actions": ["registered: a"],
"sessions_detail": [
{
"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": "-",
"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",
"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);
expect(snapshot.tmuxConfirmed, isTrue);
expect(snapshot.herdrConfirmed, isTrue);
expect(snapshot.drifts, hasLength(1));
expect(snapshot.drifts.single.driftClass, 'B');
expect(snapshot.sessions, hasLength(1));
@@ -56,7 +56,7 @@ void main() {
expect(row.isTerminal, isFalse);
expect(row.pane.pid, 123);
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', () {
@@ -64,7 +64,7 @@ void main() {
as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isFalse);
expect(snapshot.herdrConfirmed, isFalse);
expect(snapshot.sessions.single.name, 'bare');
expect(snapshot.sessions.single.resumeState, '?');
expect(snapshot.sessions.single.pane.pid, isNull);
@@ -85,8 +85,8 @@ void main() {
final snapshot = await service.fetchSnapshot();
expect(snapshot.yamlPath, isNotEmpty);
// sessions_detail row count must match tmux_sessions_alive's distinct names.
final aliveNames = snapshot.tmuxSessionsAlive
// sessions_detail row count must match herdr_sessions_alive's distinct names.
final aliveNames = snapshot.herdrSessionsAlive
.map((entry) => entry.split('|').first)
.toSet();
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
@@ -95,3 +95,4 @@ void main() {
timeout: const Timeout(Duration(seconds: 15)),
);
}
@@ -182,7 +182,9 @@ class PtySession {
// A. Disassociate controlling terminal
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(tmuxPaneNamePtr.cast<ffi.Char>());