Files
multi-agent-mux/.agents/skills/lib.sh
T
Godopu 40576c44ab refactor(uuid): resolve B-10 by deprecating agent_identities and removing PyYAML dependency
- Remove dead agent_identities read path and PyYAML import from workspace_uuid.py
- Defer eager PyYAML import in verify_session.py to lazy YAML fallback branch
- Simplify UUID resolution to 2-tier model (tier-1 own row ID -> tier-2 adapter scan)
- Clean up unused Drift D and ghost cache clearing in reconcile.sh and stop_session.sh
- Add 3 regression guards in tests/test_tier1_unit.py (266/266 PASS)
- Update IMPROVEMENTS.md, VERSIONS.md, and SKILL.md files
2026-08-17 10:59:03 +09:00

1741 lines
61 KiB
Bash

#!/usr/bin/env bash
# lib.sh — shared library for the multi-agent-mux-* skills.
#
# Single source of truth for the four things that were inconsistently
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
# - derive_session_name : the herdr session slug (P0-A)
# - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B)
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
#
# Source it from each script with a path computed from the script location:
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
#
# HARD RULE: the agent-sessions.yaml file is only ever written through
# atomic_dump_yaml. Never `open(yaml_path, 'w')` anywhere else.
if [ -z "${BASH_VERSION:-}" ]; then
echo "ERROR: lib.sh must be executed/sourced from bash (foreign shell detected)" >&2
# Reachable when this file is executed directly rather than sourced;
# `return` only fails (falling through to `exit`) in that path.
# shellcheck disable=SC2317
return 1 2>/dev/null || exit 1
fi
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
_sanitize_herdr_agent_name() {
local n="$1"
if [ -z "$n" ]; then
printf 'agent\n'
return 0
fi
local s
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
if [ "${#s}" -gt 32 ]; then
local h=""
if command -v shasum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v sha1sum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
else
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
fi
s="${s:0:23}-${h}"
fi
printf '%s\n' "${s:0:32}"
}
# 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)
_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'
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}"
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
if [ -z "${LOCAL_BIN:-}" ]; then
if [ -x "$HOME_DIR/bin/herdr" ]; then
LOCAL_BIN="$HOME_DIR/bin"
elif [ -x "$HOME_DIR/.local/bin/herdr" ]; then
LOCAL_BIN="$HOME_DIR/.local/bin"
else
LOCAL_BIN="$HOME/.local/bin"
fi
fi
export HOME_DIR CLAUDE_PROJECT_DIR LOCAL_BIN
# ---------------------------------------------------------------------------
# Herdr Server Isolation support
# ---------------------------------------------------------------------------
# Paths to exclude when resolving the real herdr binary (shim/wrapper dirs).
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
_HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}"
_canonical_file() {
local p="$1" t d b i=0
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
t="$(readlink "$p" 2>/dev/null)" || break
case "$t" in
/*) p="$t" ;;
*) p="$(dirname "$p")/$t" ;;
esac
i=$((i + 1))
done
d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1
b="$(basename "$p")"
printf '%s/%s\n' "$d" "$b"
}
_is_shim_path() {
case "/$1/" in
*"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*)
return 0 ;;
esac
return 1
}
_resolve_real_herdr_path() {
local dir cand save_ifs="$IFS" real_path=""
IFS=:
for dir in $PATH; do
[ -n "$dir" ] || continue
_is_shim_path "$dir" && continue
[ -x "$dir/herdr" ] || continue
cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr"
[ -n "$cand" ] || cand="$dir/herdr"
_is_shim_path "$cand" && continue
real_path="$dir/herdr"
break
done
IFS="$save_ifs"
[ -n "$real_path" ] || return 1
printf '%s\n' "$real_path"
}
has_real_herdr() {
_resolve_real_herdr_path >/dev/null 2>&1
}
_init_herdr_isolation() {
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$wrapper_dir"
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
cat <<'EOF' > "$tmp_file"
#!/usr/bin/env bash
# Herdr-to-Herdr translation shim wrapper
set -euo pipefail
# Dynamic real herdr path resolution to prevent infinite recursion
_resolve_real_herdr() {
local dir save_ifs="$IFS" real_path=""
IFS=:
for dir in $PATH; do
if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then
real_path="$dir/herdr"
break
fi
done
IFS="$save_ifs"
if [ -z "$real_path" ]; then
real_path="herdr"
fi
echo "$real_path"
}
REAL_HERDR=$(_resolve_real_herdr)
# Support parsing -L <session> before the subcommand
while [ "${1:-}" = "-L" ]; do
if [ $# -lt 2 ]; then
echo "herdr shim: -L requires an argument" >&2
exit 1
fi
export HERDR_SESSION_NAME="$2"
shift 2
done
_MAM_SESSION="${HERDR_SESSION_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 &
_mam_server_pid=$!
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
kill -0 "$_mam_server_pid" 2>/dev/null || break
sleep 0.25
done
fi
fi
_real_herdr() {
local session="${HERDR_SESSION_NAME:-}"
if [ "$session" = "default" ]; then
session=""
fi
if [ -n "$session" ]; then
"$REAL_HERDR" --session "$session" "$@"
else
"$REAL_HERDR" "$@"
fi
}
_sanitize_herdr_agent_name() {
local n="$1"
if [ -z "$n" ]; then
printf 'agent\n'
return 0
fi
local s
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
if [ "${#s}" -gt 32 ]; then
local h=""
if command -v shasum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v sha1sum >/dev/null 2>&1; then
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
else
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
fi
s="${s:0:23}-${h}"
fi
printf '%s\n' "${s:0:32}"
}
cmd="${1:-}"
if [ -z "$cmd" ]; then
echo "herdr shim: no command specified" >&2
exit 1
fi
shift
case "$cmd" in
agent)
sub="${1:-}"
shift || true
if [ "$sub" = "prompt" ] && [ $# -ge 2 ]; then
t="$1"
txt="$2"
shift 2
at=$(_sanitize_herdr_agent_name "$t")
_real_herdr agent prompt "$at" "$txt" "$@" 2>/dev/null || _real_herdr agent prompt "$t" "$txt" "$@"
elif [ "$sub" = "get" ] && [ $# -ge 1 ]; then
t="$1"
shift
at=$(_sanitize_herdr_agent_name "$t")
_real_herdr agent get "$at" "$@" 2>/dev/null || _real_herdr agent get "$t" "$@"
elif [ "$sub" = "read" ] && [ $# -ge 1 ]; then
t="$1"
shift
at=$(_sanitize_herdr_agent_name "$t")
_real_herdr agent read "$at" "$@" 2>/dev/null || _real_herdr agent read "$t" "$@"
else
_real_herdr agent "$sub" "$@"
fi
;;
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
if _real_herdr agent get "$(_sanitize_herdr_agent_name "$sess")" >/dev/null 2>&1 || _real_herdr agent get "$sess" >/dev/null 2>&1; then
exit 0
fi
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" python3 -c "
import sys, json, os
from lib_py.agents.sanitize import sanitize_herdr_agent_name
tn = os.environ.get('TARGET_NAME', '')
stn = sanitize_herdr_agent_name(tn)
try:
d = json.loads(sys.stdin.read())
agents = d.get('result', {}).get('agents', [])
for a in agents:
an = a.get('name', '')
if an == tn or an == stn:
sys.exit(0)
except Exception:
pass
sys.exit(1)
"; then
exit 0
fi
exit 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) shift ;;
-x|-y) shift 2 ;;
*) 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
kind=""
case "$name" in
*-creator-claude|*-planner-claude|*-reviewer-claude) kind="claude" ;;
*-creator-agy|*-planner-agy|*-reviewer-agy) kind="agy" ;;
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) kind="hermes" ;;
*-creator-cline|*-planner-cline|*-reviewer-cline) kind="cline" ;;
*)
if echo "$name" | grep -qi "agy"; then kind="agy"
elif echo "$name" | grep -qi "claude"; then kind="claude"
elif echo "$name" | grep -qi "hermes"; then kind="hermes"
elif echo "$name" | grep -qi "cline"; then kind="cline"
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
elif echo "${final_cmd:-}" | grep -qi "cline"; then kind="cline"
fi
;;
esac
# Strip duplicate agent binary name/path from final_cmd if present, preventing positional argument misinterpretation
final_cmd=$(python3 -c "
import sys, shlex
cmd = sys.argv[1]
kind = sys.argv[2]
try:
tokens = shlex.split(cmd)
if tokens:
first = tokens[0]
if first in ('claude', 'agy', 'hermes', 'cline') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'cline')) or (kind and (first == kind or first.endswith('/' + kind))):
tokens = tokens[1:]
print(' '.join(shlex.quote(t) for t in tokens))
except Exception:
print(cmd)
" "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd")
# W1: Resolve existing_ws by querying pane list for matching CWD (since WorkspaceInfo has no cwd key)
existing_ws=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_CWD', '')
try:
target_abs = os.path.realpath(target_ws) if target_ws else ''
d = json.loads(sys.stdin.read())
panes = d.get('result', {}).get('panes', [])
matched_ws = ''
for p in panes:
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
if target_abs and p_cwd == target_abs and p.get('workspace_id'):
matched_ws = p.get('workspace_id')
break
print(matched_ws)
except Exception:
pass
" 2>/dev/null || echo "")
ws_id=""
target_pane=""
if [ -n "$existing_ws" ]; then
# W2a: Determine split direction policy via pane layout
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_WS', '')
try:
d = json.loads(sys.stdin.read())
panes = d.get('result', {}).get('panes', [])
for p in panes:
if p.get('workspace_id') == target_ws and p.get('pane_id'):
print(p.get('pane_id'))
break
except Exception:
pass
" 2>/dev/null || echo "")
split_dir=""
if [ -n "$sample_pane" ]; then
split_dir=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null | MAM_MIN_COLS="${MAM_MIN_PANE_COLS:-60}" MAM_MIN_ROWS="${MAM_MIN_PANE_ROWS:-20}" python3 -c "
import sys, json, os
min_cols = int(os.environ.get('MAM_MIN_COLS', 60))
min_rows = int(os.environ.get('MAM_MIN_ROWS', 20))
try:
d = json.loads(sys.stdin.read()).get('result', {})
focused_id = d.get('focused_pane_id', '')
panes = d.get('panes', [])
anchor = None
for p in panes:
if p.get('pane_id') == focused_id:
anchor = p.get('rect', {})
break
if not anchor and panes:
anchor = panes[0].get('rect', {})
if anchor:
w = anchor.get('width', 0)
h = anchor.get('height', 0)
if w // 2 >= min_cols:
print('right')
elif h // 2 >= min_rows:
print('down')
else:
print('overflow')
except Exception:
pass
" 2>/dev/null || echo "")
fi
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
elif [ "$split_dir" = "overflow" ]; then
# W2b: Overflow threshold reached — force create fresh workspace
existing_ws=""
else
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
fi
fi
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$ws_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
w_obj = res.get('workspace', {})
print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
fi
if [ -z "$target_pane" ]; then
echo "Error: target_pane allocation failed for $name" >&2
exit 1
fi
if [ -z "$kind" ]; then
echo "Error: unable to determine agent kind for session $name" >&2
exit 1
fi
# W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors
res=""
success=0
backoffs=(0.5 1 2)
agent_name=$(_sanitize_herdr_agent_name "$name")
for i in $(seq 0 2); do
if [ -n "$final_cmd" ]; then
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
else
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
fi
if echo "$res" | grep -q "agent_started"; then
success=1
break
fi
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
break
fi
if [ "$i" -lt 2 ]; then
sleep "${backoffs[$i]}"
fi
done
if [ "$success" -ne 1 ]; then
echo "$res" >&2
exit 1
fi
;;
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.
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_real_herdr agent get "$agent_target" 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 || _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
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 || _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
;;
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>`.
agent_target=$(_sanitize_herdr_agent_name "$sess")
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
pane_id="" cwd="" cmd=""
if [ -n "$info" ]; then
parsed=$(python3 -c "
import sys, json
try:
raw = sys.stdin.read()
if '{' in raw:
raw = raw[raw.index('{'):]
a = json.loads(raw).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_pid}*#{pane_current_path}*)
printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd"
;;
*'#{pane_pid}'*|*#{pane_pid}*)
printf '%s\n' "$pid"
;;
*'#{pane_current_path}'*|*#{pane_current_path}*)
printf '%s\n' "$cwd"
;;
*'#{pane_current_command}'*|*#{pane_current_command}*)
printf '%s\n' "$cmd"
;;
*)
printf '%s|%s|%s\n' "$pid" "$cwd" "$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
agent_target=$(_sanitize_herdr_agent_name "$sess")
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null || _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).
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_real_herdr agent get "$agent_target" 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 || _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
else
_real_herdr pane send-keys "$agent_target" "$key" >/dev/null 2>&1 || _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
fi
;;
set-buffer)
buf="tmp_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="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
if [ -z "$buf" ]; then
echo "Error: -b name contains no usable characters" >&2
exit 1
fi
buf="buf_$buf"
shift 2
;;
*) text="$1"; shift ;;
esac
done
# A-3 GC: unique buffer names mean an abandoned buffer (SIGINT/SIGTERM/
# timeout between set-buffer and delete-buffer) is never overwritten, so
# it would leak forever. Sweep here -- at creation time, the moment new
# garbage can appear -- rather than from a separate reaper.
#
# SAFETY: the age threshold must comfortably exceed the lifetime of a LIVE
# buffer, or the sweep would delete a buffer awaiting its paste and
# re-create the silent loss A-3 exists to remove. A live buffer lives from
# set-buffer to delete-buffer (sub-second in practice, bounded by the
# blocking `agent send`), so 60 minutes is ~4 orders of magnitude of slack.
# Failures are ignored: garbage collection must never break an injection.
#
# The '.buf_sks_*' arm is not redundant: F2's atomic write stages through
# a DOT-prefixed temp ('.buf_sks_....tmp'), which 'buf_sks_*' cannot match.
# Without it a temp orphaned by SIGKILL between write and rename would leak
# forever, exactly like the buffers this sweep exists to reclaim.
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
mkdir -p "$buffer_dir" 2>/dev/null || true
find "$buffer_dir" \( -name 'buf_sks_*' -o -name '.buf_sks_*' \) \
-mmin +${MAM_BUFFER_GC_MINUTES:-60} -delete 2>/dev/null || true
_tmp="$buffer_dir/.$buf.$$.tmp"
if ! { echo -n "$text" > "$_tmp" && mv -f "$_tmp" "$buffer_dir/$buf"; }; then
rm -f "$_tmp"
echo "Error: failed to write buffer $buf" >&2
exit 1
fi
;;
paste-buffer)
buf="tmp_buffer"
sess=""
while [ $# -gt 0 ]; do
case "$1" in
-b)
buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
if [ -z "$buf" ]; then
echo "Error: -b name contains no usable characters" >&2
exit 1
fi
buf="buf_$buf"
shift 2
;;
-t)
if [ $# -lt 2 ]; then
echo "Error: -t requires a value" >&2
exit 1
fi
sess="$2"
shift 2
;;
*) shift ;;
esac
done
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
if [ -f "$buffer_dir/$buf" ]; then
_real_herdr agent send "$sess" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1 || true
else
echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
exit 1
fi
;;
delete-buffer)
buf="tmp_buffer"
while [ $# -gt 0 ]; do
case "$1" in
-b)
buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
if [ -z "$buf" ]; then
echo "Error: -b name contains no usable characters" >&2
exit 1
fi
buf="buf_$buf"
shift 2
;;
*) shift ;;
esac
done
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
rm -f "$buffer_dir/$buf"
;;
ls)
format=""
while [ $# -gt 0 ]; do
case "$1" in
-F)
if [ $# -lt 2 ]; then
echo "Error: -F requires a value" >&2
exit 1
fi
format="$2"
shift 2
;;
*) shift ;;
esac
done
# herdr has no session-creation timestamp: `agent list`, `agent get`,
# `pane get` and `api snapshot` all lack one. Derive it from the OS as the
# start time of the pane's ROOT process (`shell_pid`).
#
# It must NOT come from `foreground_processes[0]`: under MAM that slot
# holds the `caffeinate -i -t 300` keep-awake wrapper, which respawns every
# five minutes. Its start time creeps forward, so a session idle for longer
# than one caffeinate cycle would look "created after" its own transcript
# and be discarded by the stale-transcript guard in verify_session_uuid.
#
# This runs as ONE python process that re-implements `_real_herdr` and
# batches a single `ps` over every pid, rather than forking python and ps
# once per agent inside a shell loop: reconcile calls this on a 15s
# heartbeat, so per-agent forking shows up as a standing cost.
#
# TZ=UTC pins both `ps` (which prints lstart in the caller's zone) and
# `mktime` (which reads it back in the caller's zone) to the same zone.
# They already agree at any single TZ, so this is not about skew between
# them -- it removes the once-a-year DST ambiguity in local-time mktime.
# LC_ALL=C pins lstart's field names, which are locale-dependent.
_sess="${HERDR_SESSION_NAME:-}"
if [ "$_sess" = "default" ]; then
_sess=""
fi
_real_herdr agent list 2>/dev/null | \
MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
TZ=UTC LC_ALL=C python3 -c '
import json, os, subprocess, sys, time
fmt = os.environ.get("MAM_LS_FORMAT", "")
real = os.environ.get("MAM_REAL_HERDR") or "herdr"
sess = os.environ.get("MAM_LS_SESSION") or ""
base = [real] + (["--session", sess] if sess else [])
raw = sys.stdin.read()
try:
agents = json.loads(raw).get("result", {}).get("agents", []) or []
except Exception:
# Unparseable means "we do not know", not "there are no sessions".
# Exiting nonzero lets reconcile fall into its herdr_confirmed=False path
# instead of reading empty stdout as a confirmed zero and terminating
# every live row it has on file.
sys.exit(1)
rows = []
for a in agents:
name = a.get("name") or a.get("agent") or "unknown"
pane = a.get("pane_id") or ""
pid = None
if pane:
try:
out = subprocess.run(base + ["pane", "process-info", "--pane", pane],
capture_output=True, text=True, timeout=10).stdout
pi = json.loads(out).get("result", {}).get("process_info", {})
p = pi.get("shell_pid") or pi.get("foreground_process_group_id")
if isinstance(p, int) and p > 0:
pid = p
except Exception:
pass
rows.append((name, pid))
starts = {}
pids = sorted({p for _, p in rows if p})
if pids:
try:
out = subprocess.run(["ps", "-o", "pid=,lstart=", "-p", ",".join(str(p) for p in pids)],
capture_output=True, text=True, timeout=10).stdout
for line in out.splitlines():
line = line.strip()
if not line:
continue
head, _, rest = line.partition(" ")
try:
starts[int(head)] = int(time.mktime(time.strptime(rest.strip(), "%a %b %d %H:%M:%S %Y")))
except Exception:
pass
except Exception:
pass
# Degrade to now, never to 0 or a sentinel. The consumer guard is
# if epoch and mtime(transcript) < epoch: reject
# so an over-estimate only makes it stricter, while an under-estimate
# (0 is falsy; 999999 is 1970-01-12 and below every real mtime) switches
# the guard off outright -- which is the B-4 defect.
now = int(time.time())
for name, pid in rows:
if fmt == "#{session_name}":
print(name)
else:
print(name + "|" + str(starts.get(pid) or now))
'
;;
*)
_real_herdr "$cmd" "$@"
;;
esac
EOF
chmod +x "$tmp_file" 2>/dev/null || true
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
export PATH="$wrapper_dir:$PATH"
fi
}
mam_herdr() {
_init_herdr_isolation
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
}
_herdr() {
mam_herdr "$@"
}
herdr() {
mam_herdr "$@"
}
# ---------------------------------------------------------------------------
# resolve_herdr_server <session_name>
#
# Query agent-sessions.yaml to find the herdr_session associated with a session.
# Fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME or workspace slug or 'default' if not registered.
# Prints the resolved server name on stdout.
# ---------------------------------------------------------------------------
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
load_state_json() {
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, sqlite3, json, yaml
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
db_sessions = []
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
conn.execute('PRAGMA busy_timeout = 60000')
try:
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
except sqlite3.OperationalError:
pass
try:
cursor = conn.execute('SELECT data FROM sessions')
for r in cursor.fetchall():
db_sessions.append(json.loads(r[0]))
d['herdr_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
def clean_surrogates(obj):
if isinstance(obj, str):
return obj.encode('utf-8', errors='replace').decode('utf-8')
elif isinstance(obj, dict):
return {k: clean_surrogates(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_surrogates(x) for x in obj]
return obj
d = clean_surrogates(d)
print(json.dumps(d, ensure_ascii=False))
PYEOF
}
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
# all do `HERDR_SESSION_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_session() {
local session_name="$1"
local workspace="${2:-}"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
ws = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
val = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace')
if val and val != 'default':
print(val)
sys.exit(0)
fallback = ''
if ws:
abs_ws = os.path.abspath(ws)
parent = os.path.basename(os.path.dirname(abs_ws)) or 'workspace'
work = os.path.basename(abs_ws) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
slug = f'{parent}-{work}'.lower().replace('_', '-')
import re
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lstrip('-')
fallback = f'mam-{slug}' if slug else 'mam-ws'
else:
fallback = os.environ.get('HERDR_SESSION_NAME', '') or os.environ.get('HERDR_SERVER_NAME', '')
if not fallback or fallback == 'default':
fallback = 'default'
print('WARN: resolve_herdr_session called without workspace parameter for unregistered session; falling back to default', file=sys.stderr)
print(fallback or 'default')
"
}
resolve_herdr_workspace() {
resolve_herdr_session "$@"
}
# ---------------------------------------------------------------------------
# derive_workspace_slug <workspace>
# ---------------------------------------------------------------------------
derive_workspace_slug() {
local workspace="${1:-$PWD}"
local abs parent work slug
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
parent="$(basename "$(dirname "$abs")" 2>/dev/null || echo "")"
work="$(basename "$abs" 2>/dev/null || echo "root")"
if [ -z "$parent" ] || [ "$parent" = "/" ] || [ "$parent" = "." ]; then
parent="workspace"
fi
if [ -z "$work" ] || [ "$work" = "/" ] || [ "$work" = "." ]; then
work="root"
fi
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
slug="$(printf '%s' "$slug" | sed 's/^-*//')"
if [ -z "$slug" ]; then
slug="ws"
fi
printf 'mam-%s' "$slug"
}
# ---------------------------------------------------------------------------
# mam_gen_uuid
# ---------------------------------------------------------------------------
mam_gen_uuid() {
if command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
# ---------------------------------------------------------------------------
# mam_abs_workspace <path>
# ---------------------------------------------------------------------------
mam_abs_workspace() {
local p="${1:-}"
( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
}
# ---------------------------------------------------------------------------
# mam_workspace_key <workspace>
# ---------------------------------------------------------------------------
mam_workspace_key() {
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
}
# ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent> [role]
# ---------------------------------------------------------------------------
derive_session_name() {
local workspace="${1:-$PWD}" agent="${2:-}" role="${3:-creator}"
role=$(echo "$role" | tr '[:upper:]' '[:lower:]')
local base_slug
base_slug="$(derive_workspace_slug "$workspace")"
local slug="${base_slug#mam-}"
if [ -n "$agent" ]; then
printf '%s-%s-%s' "$slug" "$role" "$agent"
else
printf '%s-%s-' "$slug" "$role"
fi
}
# ---------------------------------------------------------------------------
# env_python <yaml_path> [KEY=VALUE ...] (Python source read from stdin)
#
# Run python3 with the source supplied on stdin via a *quoted* heredoc, so the
# shell never interpolates the source. All values are passed through the
# environment (YAML_PATH plus any KEY=VALUE pairs). Untrusted data (workspace
# paths, capture-pane text) must travel as env vars and be read via os.environ
# inside the script — never spliced into the source. Read-only by convention;
# use atomic_dump_yaml when you need to write the YAML.
# ---------------------------------------------------------------------------
_validate_env_key() {
local key="$1"
if [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
echo "ERROR: Invalid environment variable name: $key" >&2
return 1
fi
case "$key" in
LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PYTHONHOME|PYTHONINSPECT|PYTHONSTARTUP)
echo "ERROR: Blocked environment variable: $key" >&2
return 1
;;
esac
return 0
}
env_python() {
local yaml_path="$1"; shift
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
while [ $# -gt 0 ]; do
case "$1" in
*=*)
local key="${1%%=*}"
_validate_env_key "$key" || return 1
envs+=("$1")
shift
;;
*)
break
;;
esac
done
local pybin
pybin="$(_delegate_py_bin)"
env "${envs[@]}" "$pybin" - "$@"
}
# ---------------------------------------------------------------------------
# atomic_dump_yaml <yaml_path> [KEY=VALUE ...] (mutation source from stdin)
#
# The ONLY sanctioned way to write agent-sessions.yaml. It:
# 1. takes an exclusive SQLite BEGIN IMMEDIATE transaction lock on
# agent-sessions.db (serialises all writers)
# 2. loads the current state into `d` (seeds from YAML if DB is empty)
# 3. exec()s the caller's mutation source (sees d, yaml, os, datetime,
# timezone, glob, subprocess; reads values via os.environ). The mutation
# may print and may `raise SystemExit(n)` to abort *without* writing.
# 4. validates the resulting schema
# 5. backs up to <yaml_path>.bak, then writes YAML atomically (temp + os.replace)
# when a session transitions to a finished state.
#
# The mutation source is passed via env and exec()'d — it is never string
# spliced and untrusted data never lands in Python source (P0-B / P1-B).
# ---------------------------------------------------------------------------
# Check if the workspace is on NFS — locking behaves differently on NFS
_check_is_nfs() {
local f="$1"
local mountpoint
mountpoint="$(df --output=target "$f" 2>/dev/null | tail -1)"
if [ -z "$mountpoint" ]; then
mountpoint="$(df -P "$f" 2>/dev/null | tail -1 | awk '{print $6}')"
fi
if [ -n "$mountpoint" ] && mount | grep -i -q -E "$mountpoint.*(nfs|cifs|smb|sshfs)"; then
return 0 # is NFS
fi
return 1 # not NFS
}
atomic_dump_yaml() {
local yaml_path="$1"; shift
if [ -z "${MAM_IS_NFS:-}" ]; then
if _check_is_nfs "$(dirname "$yaml_path")"; then
export MAM_IS_NFS="true"
echo "WARNING: $(dirname "$yaml_path") appears to be a network filesystem (NFS/CIFS/SSHFS)." >&2
echo "WARNING: SQLite journal_mode automatically falls back to DELETE." >&2
else
export MAM_IS_NFS="false"
fi
fi
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "MAM_IS_NFS=$MAM_IS_NFS" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
while [ $# -gt 0 ]; do
case "$1" in
*=*)
local key="${1%%=*}"
_validate_env_key "$key" || return 1
envs+=("$1")
shift
;;
*)
break
;;
esac
done
local mutation=""; mutation="$(cat)"
local pybin; pybin="$(_delegate_py_bin)"
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" "$pybin" - <<'PYEOF'
from lib_py.atomic_yaml import atomic_dump_yaml_main
atomic_dump_yaml_main()
PYEOF
}
# ---------------------------------------------------------------------------
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
# ---------------------------------------------------------------------------
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
else
echo "ERROR: lib_py/verify_session.py missing" >&2
return 1 2>/dev/null || exit 1
fi
verify_session_uuid() {
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json
from lib_py.verify_session import verify_session_uuid
ws = os.environ['WS_ABS']
agent = os.environ['AGENT']
uuid = os.environ['UUID']
row_str = os.environ.get('ROW_JSON', '')
row = json.loads(row_str) if row_str else {}
mode = os.environ.get('MODE', 'discover')
if verify_session_uuid(ws, agent, uuid, row, mode=mode):
sys.exit(0)
sys.exit(1)
PYEOF
}
# verify_tui_viewport <session_name> <agent> <workspace>
#
# Viewport matching verification:
# | Return | Meaning | Action Required |
# |---|---|---|
# | 0 | Viewport matches workspace | Pin UUID, last_visible_status='pinned', drift class C |
# | 1 | Viewport mismatch | Do not pin, log C-warn, last_visible_status unchanged |
# | 2 | Viewport check unavailable | Pin UUID via stage 1-3 only, log C-degraded |
#
# Returns:
# 0 = verified match
# 1 = mismatch
# 2 = check not possible (capture failed, session gone)
verify_tui_viewport() {
local session_name="$1" agent="$2" workspace="$3"
local abs; abs="$(mam_abs_workspace "$workspace")"
local base; base="$(basename "$abs")"
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
return 2
fi
local pane_content
pane_content=$(_pane_capture "$session_name" 2>/dev/null || true)
if [ -z "$pane_content" ]; then
return 2
fi
local flat base_flat
flat=$(printf '%s' "$pane_content" | tr -d '[:space:]')
base_flat=$(printf '%s' "$base" | tr -d '[:space:]')
if printf '%s' "$flat" | grep -Fq "$base_flat"; then
return 0
fi
if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then
return 1
fi
return 2
}
# ---------------------------------------------------------------------------
# find_workspace_uuid <workspace> <agent>
#
# Workspace-SCOPED resolution of the resume UUID (P0-C). Resolution order:
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
# (claude_session_id_own / agy_conversation_id_own)
# 2) on-disk scan scoped to this workspace, via the agent adapter's discover()
# Prints the UUID on stdout (empty line if none). Always exits 0.
# ---------------------------------------------------------------------------
find_workspace_uuid() {
local workspace="$1" agent="$2" session_name="${3:-}"
local abs; abs="$(mam_abs_workspace "$workspace")"
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
from lib_py.workspace_uuid import find_workspace_uuid_main
find_workspace_uuid_main()
PYEOF
}
# ---------------------------------------------------------------------------
# capture_conversation_id <agent> <workdir> [session_name]
#
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
# if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free
# resolver (per-row own id -> workspace-scoped disk scan),
# so recording its result into the row before kill guarantees tier-1 on the next
# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0.
# ---------------------------------------------------------------------------
capture_conversation_id() {
local agent="$1" workdir="$2" session_name="${3:-}"
find_workspace_uuid "$workdir" "$agent" "$session_name"
}
# ---------------------------------------------------------------------------
# Session isolation — Universal Global Config (Option B, P3-1)
#
# Config-home isolation (.mam/agent_homes/<uuid>/) and legacy isolation.root
# row consumers were completely deprecated and removed in favor of:
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
# ~/.hermes, ~/.cline user configuration and credential stores.
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# is_already_stopped <session_name>
#
# Exits 0 if the row's status is 'stopped' (printing "stopped_at=<ts>" on
# stdout), 1 otherwise (including not-found). Used for idempotency: a second
# stop on an already-stopped session is a no-op.
# ---------------------------------------------------------------------------
is_already_stopped() {
local session_name="$1"
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, yaml, sqlite3, json
name = os.environ['SESSION_NAME']
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
has_sessions_table = False
try:
row = conn.execute('SELECT status, data FROM sessions WHERE name=?', (name,)).fetchone()
if row:
status, s_data_str = row[0], row[1]
if status == 'stopped':
s = json.loads(s_data_str)
print(f"stopped_at={s.get('stopped_at', '?')}")
raise SystemExit(0)
has_sessions_table = True
except sqlite3.OperationalError:
pass
if not has_sessions_table:
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
for s in d.get('herdr_sessions', []):
if s.get('name') == name and s.get('status') == 'stopped':
print(f"stopped_at={s.get('stopped_at', '?')}")
raise SystemExit(0)
conn.close()
raise SystemExit(1)
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
for s in d.get('herdr_sessions', []):
if s.get('name') == name and s.get('status') == 'stopped':
print(f"stopped_at={s.get('stopped_at', '?')}")
raise SystemExit(0)
except Exception:
pass
raise SystemExit(1)
PYEOF
}
# ---------------------------------------------------------------------------
# multi-agent-mux-delegate-job integration helpers
#
# All paths are resolved relative to lib.sh's own location (BASH_SOURCE), so the
# skill tree is relocatable — no hardcoded absolute paths (review item 6).
# ---------------------------------------------------------------------------
# _delegate_py_bin — echo the virtualenv python (walk up from .agents/skills/), else python3.
_delegate_py_bin() {
# Return cached result if available
if [ -n "${AGENT_PYTHON_BIN:-}" ] && [ -x "$AGENT_PYTHON_BIN" ]; then
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
fi
if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "$VIRTUAL_ENV/bin/python" ]; then
AGENT_PYTHON_BIN="$VIRTUAL_ENV/bin/python"
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
fi
local d
d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while [ "$d" != "/" ] && [ -n "$d" ]; do
if [ -x "$d/.venv/bin/python" ]; then
AGENT_PYTHON_BIN="$d/.venv/bin/python"
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
fi
d="$(dirname "$d")"
done
AGENT_PYTHON_BIN="$(command -v python3 || echo python3)"
printf '%s\n' "$AGENT_PYTHON_BIN"
}
# _delegate_script <name> — echo the path to a multi-agent-mux-delegate-job script, resolved
# relative to .agents/skills/ (lib.sh dir). Empty if not found.
_delegate_script() {
local name="$1" skill_dir cand
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cand="$skill_dir/multi-agent-mux-delegate-job/scripts/$name"
if [ -f "$cand" ]; then printf '%s\n' "$cand"; return 0; fi
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)"
}
# delegate_submit_job <prompt> <agent> <agent_session>
#
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
delegate_submit_job() {
local prompt="$1" agent="$2" session="$3"
local py_bin registry_py
py_bin="$(_delegate_py_bin)"
registry_py="$(_delegate_script registry.py)"
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
return 1
fi
"$py_bin" "$registry_py" register \
--prompt "$prompt" \
--agent "$agent" \
--agent-session "$session"
}
# delegate_publish_event <job_id> <event> [detail]
#
# Publish a lifecycle event to the multi-agent-mux-delegate-job registry. Consolidates the
# inline .venv-walk + publish_event.py blocks that were duplicated across
# create/delete/resume (review item 7). Non-fatal by contract: an empty job id,
# a missing script, or a broker failure never aborts the caller.
delegate_publish_event() {
local job_id="$1" event="$2" detail="${3:-}"
[ -n "$job_id" ] || return 0
local py_bin pub
py_bin="$(_delegate_py_bin)"
pub="$(_delegate_script publish_event.py)"
[ -n "$pub" ] && [ -f "$pub" ] || return 0
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true
}
# start_watchdog <job_id> [workdir]
# Spawns a watchdog process to monitor a delegate-job JOB in the background.
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
# limit we set) and exits automatically when the JOB reaches terminal state.
# Returns the watchdog PID via stdout.
start_watchdog() {
local job_id="$1"
local workdir="${2:-$PWD}"
local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
local log_file="$workdir/.mam/multi-agent-mux-monitor.log"
if [ ! -f "$monitor_script" ]; then
echo "ERROR: monitor script not found: $monitor_script" >&2
return 1
fi
# Check if reconcile.sh --subscribe is already running on this workspace
local pid
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
if [ -z "$pid" ]; then
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
# and ensure it runs with $workdir as cwd to anchor relative log paths.
local orig_pwd="$PWD"
cd "$workdir" || return 1
nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &
pid=$!
cd "$orig_pwd" || return 1
fi
echo "$pid"
}
# wait_for_tui_ready <session_name> <agent>
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
wait_for_tui_ready() {
local sess="$1" agent="$2"
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)).
local tokens="${MAM_READY_TOKENS:-}"
if [ -z "$tokens" ]; then
local _facts=""
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')"
[ -n "$tokens" ] && eval "tokens=$tokens"
fi
if [ -z "$tokens" ]; then
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
return 1
fi
local i
for i in {1..30}; do
if _pane_dialog_open "$sess"; then
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
_sks_herdr send-keys -t "$sess" Enter || true
sleep 1
fi
sleep 1
continue
fi
local content
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then
echo "✅ $agent TUI detected ready."
return 0
fi
fi
sleep 1
done
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
return 1
}
# inject_instructions <session_name> <instructions> [job_id]
# Injects instructions into the herdr session using paste-buffer and C-m.
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
inject_instructions() {
send_keys_safe "$1" "$2" "${3:-onboard}"
}
# ---------------------------------------------------------------------------
# Prompt-lock safe delivery (FW-W2). Keys are sent on evidence, not timers.
# send_keys_safe returns 0 only if the text was verifiably submitted.
# Exit codes: 1=pane never quiesced 2=dialog blocking input
# 3=paste not visible 4=Enter not accepted
# Callers MUST handle non-zero.
# ---------------------------------------------------------------------------
# Server-aware herdr (same isolation rule as inject_instructions).
_sks_herdr() {
mam_herdr "$@"
}
_pane_capture() {
local raw
raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")"
if [ -z "$raw" ]; then
echo ""
return 0
fi
python3 -c '
import sys, json
raw = sys.stdin.read()
try:
data = json.loads(raw)
text = data.get("result", {}).get("read", {}).get("text")
if text is not None:
print(text, end="")
else:
print(raw, end="")
except Exception:
print(raw, end="")
' <<< "$raw"
}
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
# _wait_session_gone <sess> [max_sec=5]
# Reactive loop to wait until a herdr session goes away (happy path returns early).
_wait_session_gone() {
local sess="$1" max="${2:-5}" i
for ((i = 0; i < max * 4; i++)); do
_sks_herdr has-session -t "$sess" 2>/dev/null || return 0
sleep 0.25
done
return 1
}
# _pane_quiescent <sess> [tries=20] [interval=0.5]
# Renderer settled = two consecutive identical non-empty captures.
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
_pane_quiescent() {
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
for ((i = 0; i < tries; i++)); do
cur=$(_pane_capture "$sess")
[ -z "$cur" ] && { sleep "$interval"; continue; }
[ "$cur" = "$prev" ] && return 0
prev="$cur"
sleep "$interval"
done
return 1
}
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
# only (dialogs render near the input area; conversation text above must not
# trigger this). Tokens must NOT appear on normal idle prompt screens.
_pane_dialog_open() {
_pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS"
}
# send_keys_safe <sess> <text> [job_id]
# 1. Wait for renderer quiescence (RC-A).
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
# Escape per poll and re-check. NEVER a blind Enter.
# 3. Paste via unique buffer; verify the text landed (marker visible).
# 4. Submit C-m; verify submission (marker left the input area AND the pane
# changed); retry up to 3 times.
send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}"
local agent_target
agent_target=$(_sanitize_herdr_agent_name "$sess")
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
return 0
fi
local marker pre_submit deadline try
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
# 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; }
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
while _pane_dialog_open "$sess"; do
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
_sks_herdr send-keys -t "$sess" Escape
sleep 1
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
echo "send_keys_safe: dialog blocking input ($sess)" >&2
return 2
fi
sleep 2
done
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
_sks_herdr set-buffer -b "$sks_buf" "$text"
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
local was_popup=0
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]]; then
# Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
true
else
sleep 0.5
local pane_content
pane_content=$(_pane_capture "$sess")
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
was_popup=1
# 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
return 3
fi
fi
for try in 1 2 3; do
pre_submit=$(_pane_capture "$sess")
_sks_herdr send-keys -t "$sess" C-m
sleep "$try"
local cur_content
cur_content=$(_pane_capture "$sess")
# Hardened Submission Checks
if printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
return 0
fi
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
return 0
elif [ "$was_popup" = "1" ] && \
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
[ "$cur_content" != "$pre_submit" ]; then
return 0
fi
done
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
return 4
}
# handle_startup_dialogs <sess> [timeout_sec=20]
# Post-start/resume dialog policy for claude: accept the trust / bypass
# dialogs ONLY when their signature is positively on screen; return as soon
# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence.
handle_startup_dialogs() {
local sess="$1" timeout="${2:-20}" waited=0 pane
while [ "$waited" -lt "$timeout" ]; do
pane=$(_pane_tail "$sess" 20)
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
_sks_herdr send-keys -t "$sess" Down
sleep 0.3
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
_sks_herdr send-keys -t "$sess" Enter
elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then
return 0
fi
sleep 2
waited=$((waited + 2))
done
return 0
}
# Auto-initialize the herdr translation shim wrapper on library source
_init_herdr_isolation