Files
multi-agent-mux/.agents/skills/lib.sh
T

1687 lines
59 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)"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
export PATH="$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|projects'
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}"
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
LOCAL_BIN="${LOCAL_BIN:-$HOME/.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_SERVER_NAME="${HERDR_SERVER_NAME:-default}"
_resolve_real_herdr_path() {
_REAL_HERDR_PATH="herdr"
export _REAL_HERDR_PATH
}
_init_herdr_isolation() {
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$wrapper_dir"
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
# 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 <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
fi
fi
_real_herdr() {
local session="${HERDR_SERVER_NAME:-}"
if [ "$session" = "default" ]; then
session=""
fi
if [ -n "$session" ]; then
"$REAL_HERDR" --session "$session" "$@"
else
"$REAL_HERDR" "$@"
fi
}
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cmd="${1:-}"
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
# Check if there is an existing workspace inside this session (to reuse and split view)
existing_ws=$(_real_herdr workspace list 2>/dev/null | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
wss = d.get('result', {}).get('workspaces', [])
if wss:
print(wss[0].get('workspace_id', ''))
except Exception:
pass
")
split_flag=""
if [ -n "$existing_ws" ]; then
ws_id="$existing_ws"
split_flag="--split right"
else
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}"
fi
eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $split_flag $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
}
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_server associated with a session.
# Fallback to HERDR_SERVER_NAME or 'default' if not registered or field is missing.
# Prints the resolved server name on stdout.
# ---------------------------------------------------------------------------
load_state_json() {
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)
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
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_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"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('herdr_workspace') or s.get('herdr_server') or 'default')
sys.exit(0)
fallback = os.environ.get('HERDR_SERVER_NAME', '')
if not fallback or fallback == 'default':
pwd = os.path.abspath(os.getcwd())
fallback = os.path.basename(pwd)
print(fallback or 'default')
"
}
# ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent>
#
# THE single source of truth for the herdr session name. Rule:
# slug = the two trailing path components of the absolute workspace,
# '_' -> '-', lowercased, joined with '-'
# name = "<slug>-creator-<agent>"
#
# Workspace root 기준 상대 해석. 예:
# $WORKSPACE_ROOT/landing_page/refer_landing_page + claude
# -> landing-page-refer-landing-page-creator-claude
#
# Decision (REVIEW P0-A): the actual workspace basename (refer_landing_page)
# IS included. The hand-written historical entry that dropped it
# (lab-landing-page-creator-claude) was the bug, not the convention.
# Every script and SKILL.md must use exactly this rule.
# ---------------------------------------------------------------------------
derive_session_name() {
local workspace="$1" agent="$2"
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 '%s-creator-%s' "$slug" "$agent"
}
# ---------------------------------------------------------------------------
# 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")
while [ $# -gt 0 ]; do
case "$1" in
*=*)
local key="${1%%=*}"
_validate_env_key "$key" || return 1
envs+=("$1")
shift
;;
*)
break
;;
esac
done
env "${envs[@]}" python3 - "$@"
}
# ---------------------------------------------------------------------------
# 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)" || return 1
if mount | grep -q "$mountpoint.*nfs\|$mountpoint.*cifs\|$mountpoint.*fuse.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")
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)"
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" python3 - <<'PYEOF'
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3
from datetime import datetime, timezone
import yaml
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
def _validate(d):
if not isinstance(d, dict):
raise SystemExit("VALIDATE: top-level is not a mapping")
sessions = d.get('herdr_sessions', [])
if not isinstance(sessions, list):
raise SystemExit("VALIDATE: herdr_sessions is not a list")
valid = {'running', 'terminated', 'archived', 'stopped'}
for i, s in enumerate(sessions):
if not isinstance(s, dict):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] not a mapping")
if not s.get('name') or not s.get('status'):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] missing name/status")
if s.get('role') is not None and (not isinstance(s['role'], str) or not s['role'].strip()):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} role must be a non-empty string")
if s['status'] not in valid:
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
if not isinstance(s.get('pane'), dict):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
iso = s.get('isolation')
if iso is not None:
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
def get_terminal_set(d):
return {s.get('name'): s.get('status') for s in d.get('herdr_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')}
def get_all_sessions_status(d):
import hashlib
res = {}
for s in d.get('herdr_sessions', []):
name = s.get('name')
ser = json.dumps(s, sort_keys=True)
res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest()
return res
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
conn = sqlite3.connect(db_path, timeout=60.0)
for f in [db_path, db_path + '-wal', db_path + '-shm']:
if os.path.exists(f):
try:
os.chmod(f, 0o600)
except Exception:
pass
is_nfs = os.environ.get('MAM_IS_NFS') == 'true'
if is_nfs:
conn.execute('PRAGMA journal_mode=DELETE')
else:
conn.execute('PRAGMA journal_mode=WAL')
try:
# Disable auto-commit by explicitly starting a transaction with BEGIN IMMEDIATE
# This prevents the read-modify-write lost update race condition.
conn.execute('BEGIN IMMEDIATE')
conn.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, data TEXT)')
conn.execute('CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)')
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_pane_cwd ON sessions(pane_cwd)')
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
else:
# Seed from YAML
if os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
else:
d = {}
# Assemble d['herdr_sessions'] from sessions table if table contains data
db_sessions = []
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
for s_row in cursor.fetchall():
s_data = json.loads(s_row[3])
s_data['name'] = s_row[0]
s_data['status'] = s_row[1]
if 'pane' not in s_data:
s_data['pane'] = {}
s_data['pane']['cwd'] = s_row[2]
db_sessions.append(s_data)
if db_sessions:
d['herdr_sessions'] = db_sessions
elif 'herdr_sessions' not in d:
d['herdr_sessions'] = []
old_sessions = get_all_sessions_status(d)
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
# --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) ---
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
# Role immutability check
for s in d.get('herdr_sessions', []):
name = s.get('name')
if name in old_roles and s.get('role') != old_roles[name]:
raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}")
# ID Uniqueness Check (T2)
running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']
id_to_session = {}
for s in d.get('herdr_sessions', []):
if s.get('status') == 'running':
s_name = s.get('name')
for k in running_keys:
v = s.get(k)
if v:
if v in id_to_session and id_to_session[v] != s_name:
raise SystemExit(f"VALIDATE: Duplicate running conversation ID {v!r} detected between {id_to_session[v]!r} and {s_name!r}")
id_to_session[v] = s_name
_validate(d)
# Separate globals and sessions for normalization
d_state = {k: v for k, v in d.items() if k != 'herdr_sessions'}
conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),))
current_names = []
for s in d.get('herdr_sessions', []):
name = s.get('name')
status = s.get('status')
pane_cwd = (s.get('pane') or {}).get('cwd', '')
conn.execute('REPLACE INTO sessions (name, status, pane_cwd, data) VALUES (?, ?, ?, ?)',
(name, status, pane_cwd, json.dumps(s)))
current_names.append(name)
if current_names:
placeholders = ','.join('?' for _ in current_names)
conn.execute(f'DELETE FROM sessions WHERE name NOT IN ({placeholders})', current_names)
else:
conn.execute('DELETE FROM sessions')
new_sessions = get_all_sessions_status(d)
conn.commit()
# Write to YAML when sessions status or session list changes (e.g. create/stop/resume)
if new_sessions != old_sessions:
if os.path.exists(yaml_path):
try:
shutil.copy2(yaml_path, yaml_path + '.bak')
except Exception:
pass
dir_ = os.path.dirname(yaml_path) or '.'
fd, tmp = tempfile.mkstemp(dir=dir_, prefix='.agent-sessions.', suffix='.tmp')
try:
with os.fdopen(fd, 'w') as f:
yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False,
allow_unicode=True, width=4096)
os.replace(tmp, yaml_path)
except Exception:
if os.path.exists(tmp):
os.remove(tmp)
raise
try:
conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
except Exception:
pass
except Exception:
conn.rollback()
raise
finally:
conn.close()
# H3: Re-apply chmod 0600 after close to cover newly created -wal / -shm files
try:
os.chmod(db_path, 0o600)
wal = db_path + '-wal'
if os.path.exists(wal): os.chmod(wal, 0o600)
shm = db_path + '-shm'
if os.path.exists(shm): os.chmod(shm, 0o600)
except Exception:
pass
PYEOF
}
# ---------------------------------------------------------------------------
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
# ---------------------------------------------------------------------------
VERIFY_SESSION_PYTHON='
def workspace_key(path):
return path.replace("/", "-").replace("_", "-")
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3
home = home_dir or os.environ.get("HOME_DIR", "")
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {}
epoch = row.get("herdr_session_epoch", 0)
cwd = row.get("pane", {}).get("cwd", "") or ws
if workspace_key(cwd) != workspace_key(ws):
return False
if agent == "claude":
base = c_dir
key = workspace_key(ws)
path = f"{base}/{key}/{uuid}.jsonl"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
with open(path) as f:
first = f.readline().strip()
if not first:
return False
payload = json.loads(first)
if payload.get("sessionId") != uuid:
return False
if payload.get("cwd") and payload.get("cwd") != cwd:
return False
except Exception:
return False
elif agent == "agy":
base = f"{home}/.gemini/antigravity-cli/conversations"
path = f"{base}/{uuid}.db"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
if mode == "discover":
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False
if os.path.exists(lc):
try:
with open(lc) as f:
lc_data = json.load(f)
cache_match = (lc_data.get(cwd) == uuid)
except Exception:
cache_match = False
if not cache_match:
if uuid in (row.get("_sibling_claimed_uuids") or []):
return False
try:
conn = sqlite3.connect(path)
r = conn.execute("SELECT count(*) FROM steps").fetchone()
conn.close()
if not r or r[0] < 1:
return False
except Exception:
return False
elif agent == "hermes":
hdb = f"{home}/.hermes/state.db"
if not os.path.exists(hdb):
return False
if epoch and os.path.getmtime(hdb) < epoch:
return False
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone()
conn.close()
if not r:
return False
except Exception:
return False
elif agent == "cline":
base = f"{home}/.cline/data/sessions"
path = f"{base}/{uuid}/{uuid}.json"
if not os.path.exists(path):
return False
if epoch and os.path.getmtime(path) < epoch:
return False
try:
with open(path) as f:
sdata = json.load(f)
if sdata.get("session_id") != uuid:
return False
except Exception:
return False
return True
'
verify_session_uuid() {
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json
exec(os.environ['MAM_VERIFY_PY'])
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="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$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
case "$agent" in
claude)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
agy|hermes|cline)
if printf '%s\n' "$pane_content" | grep -q "$base"; then
return 0
fi
return 1
;;
*)
return 2
;;
esac
}
# ---------------------------------------------------------------------------
# find_workspace_uuid <workspace> <agent>
#
# Workspace-SCOPED resolution of the resume UUID (P0-C). It NEVER returns a
# global agent_identities id unless that id's project_cwd matches THIS
# workspace. Resolution order:
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
# (claude_session_id_own / agy_conversation_id_own)
# 2) on-disk scan scoped to this workspace
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
# 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="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json, glob, sqlite3
ws = os.environ['WS_ABS']
agent = os.environ['AGENT']
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
target = os.environ.get('TARGET_SESSION', '')
exec(os.environ['MAM_VERIFY_PY'])
OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'}
def iso_root_of(s):
iso = s.get('isolation')
return iso.get('root') if isinstance(iso, dict) else None
# Ingest the merged state dictionary from stdin pipeline
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
running_ids = set()
for s_item in d.get('herdr_sessions', []):
if s_item.get('status') == 'running':
if target and s_item.get('name') == target:
continue
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
val = s_item.get(k)
if val:
running_ids.add(val)
def emit(u):
if u in running_ids:
return
print(u)
raise SystemExit(0)
# Fetch all sessions matching this workspace
sessions = []
for s in d.get('herdr_sessions', []):
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
sessions.append(s)
if target:
for s in sessions:
if s.get('name') != target:
continue
iso = iso_root_of(s)
cand = s.get(OWN_KEY.get(agent, ''), None)
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if iso:
key = workspace_key(ws)
if agent == 'claude':
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'agy':
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-3]
if cand and verify_session_uuid(ws, agent, cand, s):
emit(cand)
elif agent == 'hermes':
hdb = f"{iso}/.hermes/state.db"
if os.path.exists(hdb):
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
conn.close()
if r:
cand = r[0]
if verify_session_uuid(ws, agent, cand, s):
emit(cand)
except Exception:
pass
elif agent == 'cline':
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
fn = os.path.basename(folder)
if os.path.exists(f"{folder}/{fn}.json"):
if verify_session_uuid(ws, agent, fn, s):
emit(fn)
print('')
raise SystemExit(0)
for s in sessions:
name = s.get('name', '')
if agent == 'claude' and name.endswith('-creator-claude'):
cand = s.get('claude_session_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'agy' and name.endswith('-creator-agy'):
cand = s.get('agy_conversation_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'hermes' and name.endswith('-creator-hermes'):
cand = s.get('hermes_conversation_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'cline' and name.endswith('-creator-cline'):
cand = s.get('cline_conversation_id_own')
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
emit(cand)
if agent == 'claude':
key = workspace_key(ws)
proj = f"{claude_project_dir}/{key}"
if os.path.isdir(proj):
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
cand = os.path.basename(j)[:-6]
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'agy':
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
if os.path.exists(lc):
cand = None
try:
cand = json.load(open(lc)).get(ws)
except Exception:
cand = None
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'hermes':
hdb = f"{home}/.hermes/state.db"
if os.path.exists(hdb):
cand = None
try:
conn = sqlite3.connect(hdb)
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
conn.close()
if r:
cand = r[0]
except Exception:
cand = None
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
elif agent == 'cline':
sessions_dir = f"{home}/.cline/data/sessions"
if os.path.isdir(sessions_dir):
candidates = []
for session_folder in glob.glob(f"{sessions_dir}/*"):
if os.path.isdir(session_folder):
folder_name = os.path.basename(session_folder)
json_file = f"{session_folder}/{folder_name}.json"
if os.path.exists(json_file):
candidates.append(json_file)
candidates.sort(key=os.path.getmtime, reverse=True)
for j in candidates:
cand = os.path.basename(j)[:-5]
if cand and verify_session_uuid(ws, agent, cand):
emit(cand)
ai = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
ai = json.loads(row[0]).get('agent_identities', {})
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
ai = d.get('agent_identities', {})
except Exception:
pass
ai_agent = ai.get(agent) or {}
if ai_agent.get('project_cwd') == ws:
if agent == 'claude':
cand = ai_agent.get('session_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'agy':
cand = ai.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'hermes':
cand = ai_agent.get('session_id') or ai.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
elif agent == 'cline':
cand = ai_agent.get('session_id') or ai.get('conversation_id')
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
emit(cand)
print('')
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, 3-tier, race-free
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
# so recording its result into the row before kill guarantees tier-1 on the next
# resume. Pass session_name to scope resolution to that row (required for
# isolated sessions — see isolation block / T5). 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 A, Job 536a6625)
#
# Config-home isolation (.mam/agent_homes/<uuid>/) was 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.
#
# Stubbed isolation functions kept for backward compatibility:
# ---------------------------------------------------------------------------
provision_isolation() {
local agent="$1" root="$2"
printf ''
}
isolation_lever() {
case "$1" in
claude|agy|hermes|cline) echo "none" ;;
*) echo "" ;;
esac
}
isolation_env_prefix() {
:
}
isolation_cmd_args() {
:
}
# ---------------------------------------------------------------------------
# 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 (shell variable, not exported — avoids cross-workspace pollution)
if [ -n "${AGENT_PYTHON_BIN:-}" ] && [ -x "$AGENT_PYTHON_BIN" ]; then
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 >> "$log_file" 2>&1 &
pid=$!
cd "$orig_pwd" || return 1
fi
echo "$pid"
}
# wait_for_tui_ready <session_name> <agent>
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
wait_for_tui_ready() {
local sess="$1" agent="$2"
local local_herdr="herdr" i
if [ -n "${HERDR_SERVER_NAME:-}" ] && [ "$HERDR_SERVER_NAME" != "default" ]; then
local_herdr="herdr -L $HERDR_SERVER_NAME"
fi
for i in {1..30}; do
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
case "$agent" in
claude)
if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then
echo "✅ Claude TUI detected ready."
return 0
fi
;;
agy)
if echo "$content" | grep -q "Antigravity" 2>/dev/null; then
echo "✅ Antigravity TUI detected ready."
return 0
fi
;;
hermes)
if echo "$content" | grep -q "Hermes" 2>/dev/null; then
echo "✅ Hermes TUI detected ready."
return 0
fi
;;
cline)
if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then
echo "✅ Cline TUI detected ready."
return 0
fi
;;
esac
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() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; }
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
# _wait_session_gone <sess> [max_sec=5]
# Reactive loop to wait until a 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")
[ -n "$cur" ] && [ "$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 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
_sks_herdr set-buffer -b "sks_$job_id" "$text"
_sks_herdr paste-buffer -b "sks_$job_id" -t "$sess"
_sks_herdr delete-buffer -b "sks_$job_id" 2>/dev/null || true
if [[ "$sess" =~ "agy" ]]; then
_sks_herdr send-keys -t "$sess" C-m
return 0
fi
local was_popup=0
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]]; then
# Skip strict paste check due to scrollout false-positives, proceed to C-m 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 -Eq "● |✽ |[A-Za-z]+ing…|[A-Za-z]+ing\.\.\.|esc to interrupt"; 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