Files
multi-agent-mux/.agents/skills/lib.sh
T
GodopuandClaude Opus 5.5 ede9c62be3 fix(id_order): keep an orphaned marker blocking while its herdr session is alive (Issue #63)
A create/resume killed after spawning its pane but before registering its
row left a marker whose owner pid was dead; the next same-cwd waiter pruned
it on the pid alone and could start a second session that discovers the
same workspace conversation id (PR #69 review P1).

- Markers record the herdr server. A dead owner's marker is pruned only once
  its row is registered for the attempt (running, newer epoch: the row
  decides) or `_herdr has-session` reports the session gone; an unanswered
  probe counts as alive. On timeout the kill-session command is printed.
- A live marker of another process for the waiter's own session name is kept
  and blocks instead of being deleted; the waiter's own pid's markers go.
- Tests: orphan / registered / gone / same-session / missing pane pid cases,
  and a create-level reproduction against mock herdr (fails before the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 07:39:43 +09:00

3222 lines
124 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)}"
export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.9.9"
export MAM_VERSION
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)
fi
if [ -n "$h" ]; then
s="${s:0:23}-$h"
else
s="${s:0:32}"
fi
fi
printf '%s\n' "$s"
}
# 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_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$HOME}"
# claude keeps projects/ under its config root, which CLAUDE_CONFIG_DIR moves (Issue #31).
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${CLAUDE_CONFIG_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/"*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*)
return 0 ;;
esac
# A wrapper directory named *-shim, the directory itself only: an ancestor
# such as a pytest basetemp "pytest-x-shim" above a mock's bin/ made the
# resolver skip the mock and run the real herdr.
case "${1%/}" in
*-shim) 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 "$(dirname "$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
# This shim can run as a standalone subprocess (e.g. spawned directly by
# herdr) without inheriting the parent shell's PYTHONPATH export from
# lib.sh, which breaks the `python3 -m lib_py...` / `from lib_py...` calls
# below. Re-derive it from the shim's own on-disk location instead of
# assuming inheritance.
_SHIM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_SKILL_DIR="$(cd "$_SHIM_DIR/../../.agents/skills" 2>/dev/null && pwd || true)"
[ -n "$_SKILL_DIR" ] && export PYTHONPATH="$_SKILL_DIR:${PYTHONPATH:-}"
# 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=""
elif [ "${MAM_NO_AUTOSTART:-0}" = "1" ]; then
# ISSUE-9: query-only callers (status.sh, reconcile.sh) must never start a
# daemon as a side effect of looking. A session whose server is down just
# answers server_not_running, which every caller already reads as "no live
# agents" - the same answer a freshly bootstrapped, empty server would give.
:
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).
# ISSUE-3: plain `nohup ... & disown` only ignores SIGHUP and drops bash's
# own job-table tracking - it does NOT create a new process group/session,
# so the daemon stays in the caller's PGID and dies when a process-group
# signal (SIGTERM/SIGINT from a test runner, timeout wrapper, or parent
# supervisor teardown) is broadcast to that group. setsid(1) is not
# installed by default on macOS, so spawn via Python's
# start_new_session=True (os.setsid in the child) instead - portable
# across macOS and Linux, and lib_py already depends on Python.
#
# ISSUE-9: the caller is usually an agent CLI itself (a Claude Code
# orchestrator pane), and herdr hands the daemon's environment to every
# pane it later spawns. An inherited CLAUDE_CODE_CHILD_SESSION makes each
# sub-agent claude disable transcript saving (no <uuid>.jsonl, so resume
# is impossible), and the other markers advertise the orchestrator's own
# identity to its sub-agents. Drop only per-session runtime markers - user
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
# inherits. The family ids mirror what orc_onboard.sh auto-detects.
#
# ISSUE-45: the server restores its saved layout on start and would
# relaunch every saved agent with herdr's own resume command (no permission
# flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's
# herdr config with [session] resume_agents_on_restore = false, rebuilt on
# every start and checked by this herdr's `config check`
# (lib_py/herdr_config.py). Without that file the server still starts -
# with restore on, as before.
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." --herdr "$REAL_HERDR" || true)
export MAM_SERVER_CFG
if [ -z "$MAM_SERVER_CFG" ]; then
echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2
fi
_mam_server_pid=$(python3 -c '
import os, subprocess, sys
deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PID",
"CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET",
"CLAUDE_CODE_BRIDGE_SESSION_ID",
"ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID",
"OPENCODE_SESSION_ID", "MAM_SERVER_CFG")
env = {k: v for k, v in os.environ.items() if k not in deny}
if os.environ.get("MAM_SERVER_CFG"):
env["HERDR_CONFIG_PATH"] = os.environ["MAM_SERVER_CFG"]
p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL)
print(p.pid)
' "$REAL_HERDR" --session "$_MAM_SESSION" server)
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
if [ "${1:-}" = "--session" ] || [ "${1:-}" = "-s" ]; then
"$REAL_HERDR" "$@"
else
"$REAL_HERDR" --session "$session" "$@"
fi
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}"
}
# ---------------------------------------------------------------------------
# Workspace scoping (ISSUE-3).
#
# Hard filter when HERDR_WORKSPACE_ID is set, else the workspace id this
# shim last persisted under $WORKSPACE_ROOT/.mam/herdr_workspace_id.
# Do not infer from cwd — that silently blocks legitimate cross-workspace
# lookups. No env and no file keeps the previous server-global lookup.
# ---------------------------------------------------------------------------
_herdr_ws_id_file() {
if [ -n "${WORKSPACE_ROOT:-}" ]; then
printf '%s\n' "$WORKSPACE_ROOT/.mam/herdr_workspace_id"
fi
}
_herdr_persist_ws_id() {
local id="$1" f
[ -n "$id" ] || return 0
f=$(_herdr_ws_id_file)
[ -n "$f" ] || return 0
mkdir -p "$(dirname "$f")" 2>/dev/null || true
printf '%s\n' "$id" > "$f" 2>/dev/null || true
}
_herdr_ws_scope() {
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
printf '%s\n' "$HERDR_WORKSPACE_ID"
return 0
fi
local f id=""
f=$(_herdr_ws_id_file)
if [ -n "$f" ] && [ -f "$f" ]; then
id=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
fi
printf '%s\n' "$id"
}
# _herdr_agent_get_scoped <name> [workspace_id]
#
# agent get with the same workspace predicate as _resolve_herdr_pane_id.
# Prints pane_id on stdout and returns 0 when the agent exists and is in
# scope. Returns 1 (prints nothing) if missing or in another workspace.
# Callers MUST wrap with `|| true`.
_herdr_agent_get_scoped() {
local name="$1"
local target_ws pid=""
# Default scope is the explicit env var only. The persisted workspace
# file is NOT used here: one MAM workspace can own several herdr
# workspaces (layout overflow / parallel create), so a single file
# must not reject a uniquely named agent. File scope applies to pane
# list / label lookup via _herdr_ws_scope.
if [ "$#" -ge 2 ]; then
target_ws="$2"
else
target_ws="${HERDR_WORKSPACE_ID:-}"
fi
[ -n "$name" ] || return 1
pid=$(_real_herdr agent get "$name" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tws = os.environ.get('TARGET_WS', '')
try:
a = json.load(sys.stdin).get('result', {}).get('agent', {})
if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
sys.exit(1)
pid = a.get('pane_id') or ''
if pid:
print(pid)
sys.exit(0)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "")
if [ -n "$pid" ]; then
printf '%s\n' "$pid"
return 0
fi
return 1
}
# _resolve_herdr_pane_id <target> [workspace_id]
#
# Resolve a session name / label to a real pane_id ("wN:pM").
# Strict order (no substring matching at any step):
# 1. herdr agent get <sanitized_name> (workspace-scoped)
# 2. herdr agent get <raw_name> (workspace-scoped)
# 3. herdr pane list [--workspace WS] matching
# 3-a. label exact
# 3-b. name exact
# 3-c. agent exact
# On success print pane_id on stdout and return 0; on failure print nothing
# and return 1. Callers MUST wrap with `|| true` (shim runs under set -e).
_resolve_herdr_pane_id() {
local target="$1"
# Fast path: caller already passed a real pane_id (e.g. "w1E:p1") — skip
# agent-get/pane-list resolution entirely.
if [[ "$target" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
printf '%s\n' "$target"
return 0
fi
local target_ws="${2:-$(_herdr_ws_scope)}"
# A caller may pass a workspace *label* (e.g. $MAM_WS_LABEL) rather than
# the raw workspace_id — panes only carry workspace_id, so translate a
# label to its id via `herdr workspace list` before filtering. Falls
# back to the original value on any failure/no-match (permissive; keeps
# prior behavior when $target_ws is already a raw id).
if [ -n "$target_ws" ]; then
local resolved_ws
resolved_ws=$(_real_herdr workspace list 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tws = os.environ.get('TARGET_WS', '')
try:
wss = json.load(sys.stdin).get('result', {}).get('workspaces', [])
ids = {w.get('workspace_id') for w in wss}
if tws in ids:
print(tws)
sys.exit(0)
hit = next((w.get('workspace_id') for w in wss if w.get('label') == tws), '')
if hit:
print(hit)
sys.exit(0)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "$target_ws")
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
fi
local sat pid=""
sat=$(_sanitize_herdr_agent_name "$target")
local cand get_ws
if [ "$#" -ge 2 ]; then
get_ws="$target_ws"
else
get_ws="${HERDR_WORKSPACE_ID:-}"
fi
for cand in "$sat" "$target"; do
[ -n "$cand" ] || continue
pid=$(_herdr_agent_get_scoped "$cand" "$get_ws" 2>/dev/null || true)
[ -n "$pid" ] && break
done
if [ -z "$pid" ]; then
local ws_flag=()
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c "
import sys, json, os
tn = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '')
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
try:
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
# Client-side filter in case an older herdr ignores --workspace.
if tws:
panes = [p for p in panes if p.get('workspace_id') == tws]
# ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback
for key in ('label', 'name'):
for p in panes:
v = p.get(key)
if v and (v == tn or v == tsa):
pid = p.get('pane_id') or ''
if pid:
print(pid)
sys.exit(0)
agent_kind = None
yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml')
if os.path.isfile(yaml_path):
try:
import yaml
with open(yaml_path) as f:
ydata = yaml.safe_load(f) or {}
for s in ydata.get('herdr_sessions', []):
if s.get('name') == tn or s.get('name') == tsa:
agent_kind = s.get('pane', {}).get('cmd') or ''
break
except Exception as e:
sys.stderr.write(f'warning: yaml load failed: {e}\n')
# BUG-1: only trust the generic agent_kind fallback when the caller's
# own target string IS the bare kind (e.g. target == 'agy'). Using a
# *derived* kind (looked up via the yaml entry matched by tn/tsa) to
# then broadly match any pane of this kind is unsound whenever another,
# unrelated same-kind session is alive in the workspace - it misattributes
# that unrelated pane as this target even though their names differ.
if agent_kind and (tn == agent_kind or tsa == agent_kind):
matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')]
if len(matching) == 1:
print(matching[0])
sys.exit(0)
elif len(matching) > 1:
# R-1: Ambiguous resolution with multiple same-kind panes — fail closed
sys.exit(1)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "")
fi
# Real pane_id values look like 'w1E:p1' — the workspace segment is
# alphanumeric. A digits-only pattern would reject every live pane_id.
if [[ "$pid" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
printf '%s\n' "$pid"
return 0
fi
return 1
}
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
_resolve_herdr_target() {
local raw="$1"
local sat
sat=$(_sanitize_herdr_agent_name "$raw")
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
echo "$sat"
return 0
fi
if [ "$raw" != "$sat" ] && [ -n "$(_herdr_agent_get_scoped "$raw" 2>/dev/null || true)" ]; then
echo "$raw"
return 0
fi
local from_list
from_list=$(_real_herdr agent list 2>/dev/null | TARGET_NAME="$raw" TARGET_SAN="$sat" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
import sys, json, os
tn = os.environ.get("TARGET_NAME", "")
tsa = os.environ.get("TARGET_SAN", "")
tws = os.environ.get("TARGET_WS", "")
try:
d = json.loads(sys.stdin.read())
agents = d.get("result", {}).get("agents", [])
if tws:
agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
name = a.get("name", "")
if name and (name == tn or name == tsa):
print(a.get("pane_id") or name)
sys.exit(0)
except Exception:
pass
sys.exit(1)
' 2>/dev/null || echo "")
if [ -n "$from_list" ]; then
echo "$from_list"
return 0
fi
# Explicit-env miss: do not fall through to a name that agent prompt
# would deliver into another workspace (ISSUE-3 / F-2b). File-only
# scope does not trip this — see _herdr_agent_get_scoped.
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
return 1
fi
echo "$raw"
}
if [ "$sub" = "prompt" ] && [ $# -ge 2 ]; then
t="$1"
txt="$2"
shift 2
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent prompt "$tgt" "$txt" "$@"
elif [ "$sub" = "get" ] && [ $# -ge 1 ]; then
t="$1"
shift
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent get "$tgt" "$@"
elif [ "$sub" = "read" ] && [ $# -ge 1 ]; then
t="$1"
shift
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent read "$tgt" "$@"
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 [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
exit 0
fi
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
import sys, json, os
try:
sys.path.insert(0, os.path.abspath(".agents/skills"))
from lib_py.agents.sanitize import sanitize_herdr_agent_name
except Exception:
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
tn = os.environ.get("TARGET_NAME", "")
stn = sanitize_herdr_agent_name(tn)
tws = os.environ.get("TARGET_WS", "")
try:
d = json.loads(sys.stdin.read())
agents = d.get("result", {}).get("agents", [])
if tws:
agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
an = a.get("name", "")
if an and (an == tn or an == stn):
sys.exit(0)
except Exception:
pass
sys.exit(1)
'; then
exit 0
fi
if [ -n "$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)" ]; 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-grok|*-planner-grok|*-reviewer-grok) kind="grok" ;;
*-creator-opencode|*-planner-opencode|*-reviewer-opencode) kind="opencode" ;;
*-creator-codex|*-planner-codex|*-reviewer-codex) kind="codex" ;;
*)
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 "grok"; then kind="grok"
elif echo "$name" | grep -qi "opencode"; then kind="opencode"
elif echo "$name" | grep -qi "codex"; then kind="codex"
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 "grok"; then kind="grok"
elif echo "${final_cmd:-}" | grep -qi "opencode"; then kind="opencode"
elif echo "${final_cmd:-}" | grep -qi "codex"; then kind="codex"
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', 'grok', 'opencode', 'codex') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')) 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 candidate workspaces by querying pane list for matching CWD
# (WorkspaceInfo has no cwd key). ISSUE-7: every workspace on this cwd is
# a candidate, in pane order; the first one with an idle pane or with
# room in its 2xK grid takes the agent, and a fresh workspace is created
# only when all of them are full. Taking just the first match found the
# full workspace every time and re-judged it overflow, so each agent past
# the fourth opened yet another workspace (4 + 1 + 1 instead of 4 + 2).
candidate_wss=$(_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', [])
seen = []
for p in panes:
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
wid = p.get('workspace_id')
if target_abs and p_cwd == target_abs and wid and wid not in seen:
seen.append(wid)
print('\n'.join(seen))
except Exception:
pass
" 2>/dev/null || echo "")
ws_id=""
target_pane=""
existing_ws=""
for cand_ws in $candidate_wss; do
[ -n "$cand_ws" ] || continue
# BUG-4: prefer an already-idle (agent-less) pane in the existing
# workspace over splitting a new one — avoids unbounded pane
# proliferation and 2x2 grid-layout churn when a prior agent's pane
# was left behind idle.
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_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 not p.get('agent') and p.get('pane_id'):
print(p.get('pane_id'))
break
except Exception:
pass
" 2>/dev/null || echo "")
# An idle pane is reused only when no environment has to be set: the
# pane's shell is already running, and `agent start ... -- <args>` hands
# every token after -- to the agent executable (--kind), so an
# `env K=V` prefix would reach the agent as arguments, not as its
# environment (Issue #21 R1). With env flags, fall through to
# pane split / workspace create, which take --env.
if [ -n "$idle_pane" ] && [ -z "$env_flags" ]; then
existing_ws="$cand_ws"
target_pane="$idle_pane"
break
fi
# W2a: Determine split direction policy via pane layout
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_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
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
split_dir="${split_dir:-right}"
sample_pane="${split_target:-$sample_pane}"
fi
if [ "$split_dir" = "overflow" ]; then
# W2b: this workspace's grid is full — try the next one on the same cwd
# (ISSUE-7); a fresh workspace is created below only if none has room.
continue
fi
existing_ws="$cand_ws"
[ "$split_dir" = "down" ] || split_dir="right"
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --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 "")
break
done
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" ${MAM_WS_LABEL:+--label "$MAM_WS_LABEL"} $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 "")
ws_id=$(echo "$ws_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('workspace', {}).get('workspace_id') or '')
except Exception:
pass
" 2>/dev/null || echo "")
else
if [ -n "${MAM_WS_LABEL:-}" ]; then
_real_herdr workspace rename "$existing_ws" "$MAM_WS_LABEL" >/dev/null 2>&1 || true
fi
ws_id="$existing_ws"
fi
if [ -n "${ws_id:-}" ]; then
export HERDR_WORKSPACE_ID="$ws_id"
_herdr_persist_ws_id "$ws_id"
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 -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
break
fi
# Fatal CLI errors abort immediately. agent_not_ready is the
# documented "process is up, blocked on a dialog" status — continue
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
# startup" as success: herdr returns that for a dead process too.
if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
success=1
break
fi
if [ "$i" -lt 2 ]; then
sleep "${backoffs[$i]}"
fi
done
if [ "$success" -ne 1 ]; then
# A "timed out waiting for agent startup" result is ambiguous — herdr
# returns the identical text whether the process died or the TUI
# render was just slow (see the comment above the retry loop). Rather
# than trust that text either way, re-query herdr directly: if the
# agent is actually registered and alive, treat this as success; if
# not, fall back to sending the launch command straight into the
# pane before giving up.
if echo "$res" | grep -qiE "timed out waiting for agent startup"; then
if [ -n "$(_herdr_agent_get_scoped "$agent_name" 2>/dev/null || true)" ]; then
success=1
else
if [ -n "$final_cmd" ]; then
_real_herdr pane send-text "$target_pane" "$final_cmd" >/dev/null 2>&1 || true
_real_herdr pane send-keys "$target_pane" Enter >/dev/null 2>&1 || true
fi
sleep 1
if [ -n "$(_herdr_agent_get_scoped "$agent_name" 2>/dev/null || true)" ]; then
success=1
fi
fi
fi
fi
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=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
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")
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|| _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
else
_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
fi
;;
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=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
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
echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
exit 1
fi
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -z "$pane_id" ]; then
# herdr has no `agent send` subcommand. Returning success here would let
# send_keys_safe submit an empty prompt.
echo "Error: paste-buffer could not resolve a pane for '$sess'" >&2
exit 1
fi
# Insert only. Submission is owned exclusively by send_keys_safe (ISSUE-1).
# A combined insert-and-submit command would double-submit.
if ! _real_herdr pane send-text "$pane_id" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1; then
echo "Error: pane send-text failed for '$sess' ($pane_id)" >&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
# ISSUE-9: under MAM_NO_AUTOSTART the session's server may simply be down.
# Say so in the wording reconcile.sh already treats as "empty" ("no server
# running", tmux's phrasing) instead of an opaque parse failure, which
# would flip herdr_confirmed=False and freeze drift detection for every
# server on the next heartbeat.
# Only a FAILED call whose structured error envelope carries
# error.code == "server_not_running" means "down". A successful list can
# legitimately contain that text (an agent named server_not_running-codex),
# and reading it as "down" made reconcile terminate every live row on that
# server. Any other failed call is "unknown": it exits nonzero below
# without its output ever reaching the list parser, so an error document on
# stdout cannot pass for an empty list either.
_ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$")
_ls_rc=0
_ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$?
_ls_down=0
if [ "$_ls_rc" -ne 0 ]; then
_ls_down=$(printf '%s' "$_ls_raw" | MAM_LS_ERR_FILE="$_ls_err" python3 -c '
import json, os, sys
out = sys.stdin.read()
try:
with open(os.environ["MAM_LS_ERR_FILE"]) as f:
err_text = f.read()
except Exception:
err_text = ""
down = 0
for text in [out, err_text] + out.splitlines() + err_text.splitlines():
try:
doc = json.loads(text)
except Exception:
continue
err = doc.get("error") if isinstance(doc, dict) else None
if isinstance(err, dict) and err.get("code") == "server_not_running":
down = 1
break
print(down)
' 2>/dev/null || echo 0)
fi
rm -f "$_ls_err" 2>/dev/null || true
if [ "$_ls_down" = "1" ]; then
echo "no server running (herdr session ${_sess:-default})" >&2
exit 1
fi
if [ "$_ls_rc" -ne 0 ]; then
# Unknown, never empty: keep the nonzero status and word the diagnostic
# so it matches none of reconcile.sh's empty-server phrases ("no server
# running", "no sessions", "failed to connect").
echo "herdr agent list returned rc=$_ls_rc (herdr session ${_sess:-default}); state unknown" >&2
exit 1
fi
printf '%s\n' "$_ls_raw" | \
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
}
# _herdr_call_is_cross_server
#
# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID /
# HERDR_TAB_ID, and those ids only mean something on the server that issued
# them. When a MAM script runs inside one server's pane (the orchestrator's)
# but targets another (the isolated MAM server named by HERDR_SESSION_NAME),
# the inherited ids reach the scope filters (_herdr_ws_scope,
# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and
# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a
# live session as absent: has-session, kill-session, agent prompt and the
# delegate-job preflight all answer "no such session".
#
# _herdr_caller_server — the name of the server whose pane this process runs
# in; empty when it is not in a pane at all.
#
# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a
# pane of the *default* server (the orchestrator's usual home), which is
# exactly the pane the #23 leak comes from - the live trial of this branch
# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is
# exported to every pane, and its layout names the server:
#
# $HOME/.config/herdr/herdr.sock -> default
# $HOME/.config/herdr/sessions/<name>/herdr.sock -> <name>
#
# (the same convention lib.sh's shim already relies on to find a named
# server's socket). Matched by shape, not by $HOME, so a redirected HOME does
# not break it. An unrecognised layout yields "" - treated as "not in a pane",
# which keeps today's behaviour rather than guessing.
_herdr_caller_server() {
if [ -n "${HERDR_SESSION:-}" ]; then
printf '%s\n' "$HERDR_SESSION"
return 0
fi
local sock="${HERDR_SOCKET_PATH:-}"
case "$sock" in
*/.config/herdr/herdr.sock)
printf 'default\n'
;;
*/.config/herdr/sessions/*/herdr.sock)
local d="${sock%/herdr.sock}"
printf '%s\n' "${d##*/}"
;;
*)
printf '\n'
;;
esac
return 0
}
# True only for a call aimed at a *named* server other than the caller's own:
#
# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel
# for "pass no session flag", so herdr answers on the ambient server, which
# for a caller inside a pane is that same caller's server - not a crossing;
# * no caller server (_herdr_caller_server empty) means the caller is not in
# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope,
# not a leak (H-18/H-21/H-23);
# * caller == target is the same server.
#
# A caller in the default server's pane targeting a named server IS a crossing
# (the reported #23 scenario): `default` is a real server name on the caller's
# side, only as a *target* is it the "ambient" sentinel.
_herdr_call_is_cross_server() {
local caller target="${HERDR_SESSION_NAME:-}"
case "$target" in
""|default) return 1 ;;
esac
caller="$(_herdr_caller_server)"
if [ -n "$caller" ] && [ "$caller" != "$target" ]; then
return 0
fi
return 1
}
# _herdr_scope_is_pane_inherited <herdr argv...>
#
# ISSUE-34: true for a call that names an agent (agent / has-session /
# kill-session) from inside a pane whose own workspace is the exported
# HERDR_WORKSPACE_ID. A pane exports it together with HERDR_PANE_ID
# "<workspace>:<pane>"; when the two agree the id says where the caller lives,
# not where to look - a worktree creator in its own workspace must reach a
# reviewer in mam-agent on the same server. Live agent names are unique per
# server (herdr --skill: "Names must ... be unique among live agents", "IDs and
# live agent names are scoped to one server"), so a name lookup without that
# filter cannot pick a same-named agent of another workspace: the ISSUE-3 /
# F-2b hazard exists only across servers (_herdr_call_is_cross_server). An id
# that disagrees with the pane, or one set outside any pane, is still a
# deliberate scope (H-18/H-21/H-23); new-session and pane calls keep it, so a
# pane caller still creates panes in its own workspace.
_herdr_scope_is_pane_inherited() {
while [ "${1:-}" = "-L" ]; do shift 2 || return 1; done
case "${1:-}" in
agent|has-session|kill-session) ;;
*) return 1 ;;
esac
local pane="${HERDR_PANE_ID:-}"
[ -n "${HERDR_WORKSPACE_ID:-}" ] && [ -n "$pane" ] && [ "${pane%%:*}" = "$HERDR_WORKSPACE_ID" ]
}
# Issue #50 follow-up: one pane claim at a time per herdr server.
#
# The shim's new-session chooses a pane - an idle one in a workspace on the
# same cwd (BUG-4), a split of one, or the root pane of a fresh workspace - and
# then claims it with `agent start`. Two shims on one server (several creates
# at once into one folder) could choose the same idle pane in between, and the
# later `agent start` failed with agent_pane_busy. mam_herdr runs the whole
# new-session call holding one lock per server: a flock on fd 9, opened for
# the block below and taken by lib_py.pane_lock (which also bounds the wait,
# MAM_PANE_CLAIM_WAIT_S). It ends with the block, or when every process that
# holds fd 9 - this shell and the shim it runs - has ended, however that ends,
# so a dead holder leaves nothing behind. Nothing long-lived may inherit fd 9
# (the herdr server is spawned with close_fds; review 66680d9d); the file is the target
# server's, so other servers' creates are not held. The lock lives here, not
# in the shim heredoc, which stays byte-identical (test_issue23_24 S11).
#
# _mam_pane_claim_target <herdr argv...>: prints the server a new-session call
# names ("" for the ambient target, which lib_py.pane_lock resolves to the
# caller pane's server - the one herdr answers on - or the default server);
# rc 1 for any other call.
_mam_pane_claim_target() {
local sess="${HERDR_SESSION_NAME:-}"
while [ "${1:-}" = "-L" ]; do sess="${2:-}"; shift 2 || return 1; done
[ "${1:-}" = "new-session" ] || return 1
[ "$sess" = "default" ] && sess=""
printf '%s\n' "$sess"
}
# _mam_with_pane_claim <server> <cmd...>: run <cmd> holding <server>'s pane lock.
_mam_with_pane_claim() {
local sess="$1" f=""
shift
f=$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock path --session "$sess") || f=""
if [ -z "$f" ]; then
echo "ERROR: no pane lock path for herdr session ${sess:-default} (lib_py.pane_lock); not placing this session" >&2
return 1
fi
mkdir -p "$(dirname "$f")"
{
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 9)" in
locked) ;;
timeout)
echo "ERROR: another pane claim on herdr session ${sess:-default} did not end within MAM_PANE_CLAIM_WAIT_S; not placing this session" >&2
return 1 ;;
*)
echo "ERROR: the pane lock of herdr session ${sess:-default} could not be decided (lib_py.pane_lock); not placing this session" >&2
return 1 ;;
esac
"$@"
} 9>>"$f"
}
mam_herdr() {
_init_herdr_isolation
# Built as an argv array and decided inside `if`: this runs under
# `set -euo pipefail` in most callers, where a bare failing test would abort
# the whole script instead of this one call.
local -a _scope_strip=()
if _herdr_call_is_cross_server; then
_scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID)
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2
fi
elif _herdr_scope_is_pane_inherited "$@"; then
_scope_strip=(env -u HERDR_WORKSPACE_ID)
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
echo "mam_herdr: '$1' looks up by name; the caller pane's own workspace ${HERDR_WORKSPACE_ID} is not a scope" >&2
fi
fi
local _claim_sess=""
if _claim_sess=$(_mam_pane_claim_target "$@"); then
_mam_with_pane_claim "$_claim_sess" ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
return
fi
${_scope_strip[@]+"${_scope_strip[@]}"} "$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
}
# resolve_agent_type_from_registry <session_name>
#
# 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는
# lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다:
# ① row['agent'] 명시 필드
# ② 세션명 접미사 (*-{creator,planner,reviewer}-<agent> 및 *-<agent>)
# ③ pane.cmd (정확히 일치하거나 .../<agent> 바이너리 경로)
# 성공하면 에이전트명을 stdout 에 출력하고 0 을, 셋 다 실패하면 아무것도
# 출력하지 않고 1 을 반환한다. 오류 메시지는 호출자가 소유한다 — 각 스크립트가
# 문서화한 종료 코드를 그대로 유지하기 위해서다.
#
# NOTE: agent_of_row 의 match_cmd=True 는 "비-입양 조회" 계약이다. reconcile.sh
# 입양 루프는 이 헬퍼를 쓰면 안 된다 (3aee63cf §1.2 실측 반증).
resolve_agent_type_from_registry() {
local name="$1"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$name" python3 -c "
import os, json, sys
from lib_py.agents.registry import agent_of_row
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
resolved = agent_of_row(row, session_name=name)
if not resolved:
sys.exit(1)
print(resolved)
"
}
# resolve_herdr_session <session_name> [workspace]
#
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
# not a workspace label. 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:
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
val = s.get('herdr_session') or s.get('herdr_server')
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 <session_name> [workspace]
#
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
#
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
# ① row['herdr_workspace'] — 명시 기록
# ② row['pane']['cwd'] 의 슬러그 — 등록된 세션의 실제 작업 디렉터리
# ③ 인자 workspace 의 슬러그 — 미등록 세션 전용 폴백
# ④ 빈 문자열
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
# pane.cwd 를 물려받기 때문 (D5).
resolve_herdr_workspace() {
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, re
name = os.environ['SESSION_NAME']
ws = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
def slug(path):
if not path:
return ''
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)
# ① 명시 기록
if row and row.get('herdr_workspace'):
print(row['herdr_workspace']); sys.exit(0)
# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
if row:
derived = slug((row.get('pane') or {}).get('cwd', ''))
if derived:
print(derived); sys.exit(0)
# ③ 미등록(또는 cwd 부재) 세션 폴백
if ws:
derived = slug(ws)
if derived:
print(derived); sys.exit(0)
print('')
"
}
# ---------------------------------------------------------------------------
# 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
}
# ---------------------------------------------------------------------------
# mam_session_ids <stop|resume> [args...]
# Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids).
# ---------------------------------------------------------------------------
mam_session_ids() {
env_python "$AGENT_SESSIONS_YAML" "$@" <<'PYEOF'
import sys
from lib_py.agents.session_ids import main
sys.exit(main(sys.argv[1:]))
PYEOF
}
# ---------------------------------------------------------------------------
# mam_id_order_wait <workspace> <agent> <session> [uuid]
# Issue #63: same-cwd creation/resume ordering for post-discovered agents.
# ---------------------------------------------------------------------------
mam_id_order_wait() {
local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}"
local abs; abs="$(mam_abs_workspace "$workspace")"
env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \
--session "$session_name" --owner-pid "$$" --uuid "$uuid" \
--herdr-server "${HERDR_SESSION_NAME:-}" <<'PYEOF'
import sys
from lib_py.agents.id_order import main
sys.exit(main(sys.argv[1:]))
PYEOF
}
# ---------------------------------------------------------------------------
# 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 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_prompt_file <prompt> — write the prompt to a new file under the
# control root's .mam/ and echo its path. Issue #36: a prompt handed to an exec
# as one argv string is capped at 131,072 bytes; the caller removes the file.
_delegate_prompt_file() {
local dir="${WORKSPACE_ROOT:-$PWD}/.mam" f
mkdir -p "$dir" || return 1
f="$(mktemp "$dir/prompt-submit-XXXXXX")" || return 1
printf '%s' "$1" > "$f" || { rm -f "$f"; return 1; }
printf '%s\n' "$f"
}
# 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_legacy() {
local prompt="$1" agent="$2" session="$3"
local py_bin registry_py prompt_file rc=0
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
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
"$py_bin" "$registry_py" register \
--prompt-file "$prompt_file" \
--agent "$agent" \
--agent-session "$session" || rc=$?
rm -f "$prompt_file"
return $rc
}
# 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_legacy() {
local job_id="$1" event="$2" detail="${3:-}"
local tok="${4:-}" att="${5:-}"
[ -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
local -a extra=()
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && extra+=(--attempt-id "$att")
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || 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_legacy() {
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)
# Issue #19: MAM_NO_SUBSCRIBER_DAEMON=1 reports a running daemon but never
# starts one (test sandboxes; externally supervised monitors).
if [ -z "$pid" ] && [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
echo "subscriber daemon not started (MAM_NO_SUBSCRIBER_DAEMON=1)" >&2
elif [ -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
# Byte-identical to the python body: nothing on stdout when no daemon runs.
if [ -n "$pid" ]; then
echo "$pid"
fi
}
# ---------------------------------------------------------------------------
# Python-backed delegation helpers (issue #5 phase 1).
#
# The bodies above are kept verbatim as _*_legacy and are still reachable with
# MAM_DELEGATION_PY=0, which is the instant rollback switch: no redeploy, no
# revert. They are removed in phase 4, once the python path has soaked.
#
# PYTHONPATH is passed explicitly (same pattern as env_python) so `-m` resolves
# lib_py from the tree this lib.sh belongs to -- that is what makes a frozen
# loop run frozen delegation code.
# ---------------------------------------------------------------------------
# _delegation_py <cli-args...>
# Run the delegation CLI from *this* tree.
#
# NOT `python -m`: for -m, sys.path[0] is the current directory, which therefore
# beats PYTHONPATH. A frozen loop whose cwd happened to contain a lib_py package
# would silently execute live code, losing B-13. An explicit insert of SKILL_DIR
# cannot be shadowed that way. cwd is deliberately left alone; the registry and
# audit-log paths resolve to MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR, then to
# $WORKSPACE_ROOT/.mam/..., and only then relative to it (Issue #15).
_delegation_py() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c '
import sys
sys.path.insert(0, sys.argv[1])
from lib_py.delegation.cli import main
sys.exit(main(sys.argv[2:]))
' "$SKILL_DIR" "$@"
}
# delegate_submit_job <prompt> <agent> <agent_session>
# Register a job. Prints the new JID on stdout and nothing else.
delegate_submit_job() {
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_submit_job_legacy "$@"; return $?; fi
local prompt="$1" agent="$2" session="$3"
local registry_py
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
local prompt_file rc=0
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
_delegation_py submit --prompt-file "$prompt_file" --agent "$agent" --agent-session "$session" || rc=$?
rm -f "$prompt_file"
return $rc
}
# delegate_publish_event <job_id> <event> [detail]
# Non-fatal by contract: an empty job id, a missing script tree, or a broker
# failure must never abort the caller.
delegate_publish_event() {
local job_id="$1" event="$2" detail="${3:-}"
local tok="${4:-}" att="${5:-}"
[ -n "$job_id" ] || return 0
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
local pub
pub="$(_delegate_script publish_event.py)"
[ -n "$pub" ] && [ -f "$pub" ] || return 0
local -a extra=()
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && extra+=(--attempt-id "$att")
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
}
# delegate_publish_cmd <job_id> [dispatch_token] [attempt_id]
# The publish command an agent is told to run, shell-quoted, with absolute
# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree
# runs it as written). create_session's onboarding uses this; the delegate-job
# wrapper still builds its own (_pub_cmd), and S7l pins the two equal
# argument-for-argument, so both hand the agent the same shape:
# `--expect-dispatch-token T --attempt-id A` make publish_event write
# data.dispatch_token / data.attempt_id into the agent's own events, which is
# the only way a `started` can name the dispatch it answers (the trial-2
# finding: create_session's hand-rolled command omitted them, the agent's
# `started` carried no token, and the token-checking waiter could never be
# satisfied by a real agent). publish_event also CAS-checks that token against
# the record, so a stale dispatch's event is refused at the source.
delegate_publish_cmd() {
local job_id="$1" tok="${2:-}" att="${3:-}"
local py_bin pub_script pub_reg pub_logs
py_bin="$(_delegate_py_bin)"
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
pub_reg="${pub_reg:-.mam/jobs}"
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$pub_reg" 2>/dev/null || true)"
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && argv+=(--attempt-id "$att")
local out
out="$(
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
printf '%q ' "${argv[@]}"
)"
printf '%s' "${out% }"
}
# delegate_job_status <job_id> — the record's status, empty if unreadable.
delegate_job_status() {
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
REC="$reg_dir/$1.json" python3 -c '
import json, os
try:
print(json.load(open(os.environ["REC"])).get("status", "") or "")
except Exception:
print("")
' 2>/dev/null || true
}
# delegate_started_recorded <job_id> [dispatch_token] [logs_dir]
# THE definition of "this dispatch's started is on disk", shared by
# delegate_wait_started (create_session's onboarding ack) and the delegate-job
# wrapper's _wait_started. Two sources, one rule:
#
# * <registry>/<job>.events.log - the local publisher's own line, whose
# data.dispatch_token must equal <dispatch_token> when one is given (a loop
# job keeps one events.log across attempts, and a stale attempt's started
# must not vouch for this one - review R1);
# * <logs_dir>/<job>/events.ndjson - the subscriber's audit record of a
# `started` it received, the only evidence a REMOTE agent (no shared disk)
# can leave. The subscriber verifies the HMAC and drops any event whose
# data.dispatch_token differs from the job's expected token before it
# writes this record (dispatch_verified), and writes dispatch_fingerprint
# = SHA-256 of that token. The file is appended to across a job's
# attempts, so the flag alone would let an earlier attempt's record
# acknowledge a later dispatch (review round 4): with <dispatch_token>
# given, a record counts only if it is dispatch_verified AND its
# fingerprint equals SHA-256(<dispatch_token>). A record with no
# fingerprint (written before this field existed) names no dispatch and
# is rejected on that path - fail closed. Without a token, any received
# started counts, as before.
#
# Never the registry status: a record leaves `pending` when another writer
# closes it or another dispatch claims it. Lines are parsed as JSON, never
# pattern-matched. rc 0 = recorded, 1 = not (yet).
delegate_logs_dir() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$1" 2>/dev/null
}
delegate_started_recorded() {
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
# Issue #57: the audit log is resolved by the one rule (next to the registry
# unless DELEGATE_JOB_LOGS_DIR says otherwise); no bash copy of the default.
# If that resolution fails, only the audit check is skipped: the registry's
# events.log is still evidence.
[ -n "$logs_dir" ] || logs_dir="$(delegate_logs_dir "$reg_dir")" || logs_dir=""
local ev_log="$reg_dir/$job_id.events.log"
local audit=""
[ -n "$logs_dir" ] && audit="$logs_dir/$job_id/events.ndjson"
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
import hashlib, json, os, sys
tok = os.environ.get("EV_TOK", "")
fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else ""
def lines(path):
if not os.path.isfile(path):
return
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
try:
e = json.loads(line)
except Exception:
continue
if isinstance(e, dict):
yield e
for e in lines(os.environ["EV_LOG"]):
if e.get("event") != "started":
continue
data = e.get("data") if isinstance(e.get("data"), dict) else {}
if not tok or data.get("dispatch_token") == tok:
sys.exit(0)
for e in lines(os.environ["EV_AUDIT"]):
if e.get("event") != "received" or e.get("source_event") != "started":
continue
if not tok:
sys.exit(0)
if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp:
sys.exit(0)
sys.exit(1)
' 2>/dev/null
}
# mam_int / mam_int_knob (Issue #26) live in lib_int.sh, shared with the
# scripts that run without lib.sh.
# shellcheck source=lib_int.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
#
# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the
# screen. The evidence is delegate_started_recorded (above); the registry's
# status is deliberately NOT evidence - a record leaves `pending` when another
# writer closes it or another dispatch claims it (review R1). The local
# publisher writes the log line before it syncs the status, so nothing that
# really started is missed. Returns 0 on the evidence, 1 if it did not appear
# within T. Never re-injects: the single-injection contract is fail-closed.
delegate_wait_started() {
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
[ -n "$job_id" ] || return 1
ack_s=$(mam_int "$ack_s" 0) || { echo "WARNING: onboarding ack timeout ${2-} ignored (not a decimal integer >= 0); using 180" >&2; ack_s=180; }
local deadline logs_dir
deadline=$(( $(date +%s) + ack_s ))
# Issue #57: resolve the audit-log root once, not once per 1 s poll.
logs_dir="$(delegate_logs_dir "${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}")" || logs_dir=""
while :; do
if delegate_started_recorded "$job_id" "$tok" "$logs_dir"; then
return 0
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
return 1
fi
sleep 1
done
}
# delegate_wait_terminal <job_id> <timeout_s>
# Issue #50: prints how the job ended - completed|error|cancelled - or
# `timeout` when it has not ended after <timeout_s>, or `missing` when its
# record is gone. The decision is lib_py.delegation.wrapper wait-terminal.
delegate_wait_terminal() {
local job_id="$1" timeout_s="$2"
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper wait-terminal \
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
}
# delegate_autopin_completed <job_id> [dispatch_token] [registry_dir]
# Issue #40: once this side has seen the job `completed`, pin the session's
# post-discovered id (lib_py.agents.autopin decides and writes; remote, human
# and pre-assigned sessions are left alone, one job/token/attempt is handled
# once). Reports on stderr; never changes the job's result or this exit code.
delegate_autopin_completed() {
local job_id="$1" token="${2:-}" reg_dir="${3:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" HOME_DIR="${HOME_DIR:-$HOME}" LIB_SH="$SKILL_DIR/lib.sh" \
"$(_delegate_py_bin)" -m lib_py.agents.autopin --job "$job_id" --registry-dir "$reg_dir" \
--yaml "$AGENT_SESSIONS_YAML" ${token:+--expect-dispatch-token "$token"} >&2 || true
}
# delegate_onboard_exit <status>
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
delegate_onboard_exit() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper onboard-exit "$1"
}
# ensure_subscriber_daemon <job_id> [workdir]
# Start the monitor subscriber for this workspace unless one is already running.
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
ensure_subscriber_daemon() {
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
_delegation_py ensure-daemon --job "$1" --workdir "${2:-$PWD}"
}
# start_watchdog — retained name; create_session.sh and operator docs use it.
start_watchdog() { ensure_subscriber_daemon "$@"; }
# wait_for_tui_ready <session_name> <agent>
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
wait_for_tui_ready() {
local sess="$1" agent="$2"
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
local prompt="${MAM_INPUT_PROMPT:-}"
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
local modal_tokens="${MAM_MODAL_TOKENS:-}"
# Self-contained resolution: fetch facts in 1 call if not already in environment
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
local _facts=""
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
if [ -n "$_facts" ]; then
eval "$_facts"
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
prompt="${MAM_INPUT_PROMPT:-}"
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
modal_tokens="${MAM_MODAL_TOKENS:-}"
fi
fi
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
strong_tokens="$MAM_READY_TOKENS"
fi
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
return 1
fi
local empty_streak=0
local empty_giveup="$(mam_int_knob MAM_READY_EMPTY_GIVEUP 5 1)"
local modal_pat="$_MAM_MODAL_TOKENS"
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
local i content tail_lines
for i in {1..30}; do
# 1. Single capture per iteration (T-3)
content=$(_pane_capture "$sess")
# Consecutive blank capture check (C-6)
if [ -z "$content" ]; then
empty_streak=$((empty_streak + 1))
if [ "$empty_streak" -ge "$empty_giveup" ]; then
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
return 2
fi
sleep 1
continue
fi
empty_streak=0
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
# handle it and do NOT declare ready on this iteration.
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
handle_startup_dialogs "$sess" 1 "$agent" || true
sleep 1
continue
fi
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
local s_matched=false
local w_matched=false
local c_matched=false
# S-layer: Strong ready token match
if [ -n "$strong_tokens" ]; then
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
s_matched=true
fi
fi
# S-layer: Placeholder match (must be >= 8 characters)
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
s_matched=true
fi
fi
# W-layer: Weak ready token match
if [ -n "$weak_tokens" ]; then
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
w_matched=true
fi
fi
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
if [ -n "$prompt" ]; then
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
c_matched=true
fi
fi
if [ -n "$rule_pattern" ]; then
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
c_matched=true
fi
fi
# Decision: S OR (W AND C)
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
echo "✅ $agent TUI detected ready."
return 0
fi
# 4. Startup dialog & hint resolution if not ready
handle_startup_dialogs "$sess" 1 "$agent" || true
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}"; }
# _sks_agent_status <sess> [target] — herdr agent_status, empty when unavailable.
_sks_agent_status() {
local raw
raw="$(_sks_herdr agent get "${2:-$1}" 2>/dev/null || _sks_herdr agent get "$1" 2>/dev/null || true)"
[ -n "$raw" ] || { echo ""; return 0; }
"$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$raw" 2>/dev/null || true
}
# _sks_job_started <job_id> — Issue #18 delivery signal: the job's own
# `started` (registry status left `pending`, or a started line in the job's
# events.log). Reads the registry the wrapper exports (SKS_REGISTRY_DIR), else
# MAM_JOBS_DIR, else $WORKSPACE_ROOT/.mam/jobs. "adhoc" (no job) is never started.
_sks_job_started() {
local job_id="${1:-adhoc}" rd
[ -n "$job_id" ] && [ "$job_id" != "adhoc" ] || return 1
rd="${SKS_REGISTRY_DIR:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
"$(_delegate_py_bin)" - "$rd" "$job_id" <<'PYEOF'
import json, os, sys
rd, jid = sys.argv[1], sys.argv[2]
rec = os.path.join(rd, jid + ".json")
try:
st = json.load(open(rec)).get("status")
if st and st != "pending":
sys.exit(0)
except Exception:
pass
log = os.path.join(rd, jid + ".events.log")
try:
with open(log) as fh:
for line in fh:
try:
if json.loads(line).get("event") == "started":
sys.exit(0)
except Exception:
continue
except OSError:
pass
sys.exit(1)
PYEOF
}
# _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] # empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)
# Renderer settled = two consecutive identical non-empty captures.
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
# Returns 0 if renderer settled (two identical non-empty captures).
# Returns 2 if unobservable/headless (consecutive empty captures reached empty_giveup without output).
# Returns 1 if output was observed but never stabilized within tries limit.
_pane_quiescent() {
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
local saw_output=0 empty_streak=0
local empty_giveup="$(mam_int_knob SKS_EMPTY_GIVEUP 3 1)"
for ((i = 0; i < tries; i++)); do
cur=$(_pane_capture "$sess")
if [ -z "$cur" ]; then
empty_streak=$((empty_streak + 1))
[ "$saw_output" = "0" ] && [ "$empty_streak" -ge "$empty_giveup" ] && return 2
sleep "$interval"
continue
fi
saw_output=1
empty_streak=0
[ "$cur" = "$prev" ] && return 0
prev="$cur"
sleep "$interval"
done
[ "$saw_output" = "0" ] && return 2
return 1
}
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
# permission / OAuth / list-selection), checked in the bottom 8 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() {
local extra="${MAM_MODAL_TOKENS:-}"
local pat="$_MAM_MODAL_TOKENS"
[ -n "$extra" ] && pat="${pat}|${extra}"
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
}
# wait_for_agent_idle <sess> [timeout_ms]
# Phase 2 사전 TUI 유휴 핸드셰이크:
# 0: idle / done (성공)
# 1: agent still working (최종 타임아웃)
# 2: blocked (대화창/모달 차단)
# 3: unknown (휴리스틱 폴백 신호)
wait_for_agent_idle() {
local sess="${1#herdr:}"
local timeout_ms="${2:-${SKS_IDLE_TIMEOUT_MS:-120000}}"
if [ "${SKS_IDLE_HANDSHAKE:-1}" = "0" ]; then
return 0
fi
local target
target=$(_sanitize_herdr_agent_name "$sess")
local raw_status="" agent_st=""
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
if [ -n "$raw_status" ]; then
agent_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$raw_status" 2>/dev/null || true)"
fi
if [ -z "$agent_st" ] || [ "$agent_st" = "unknown" ]; then
return 3
fi
if [ "$agent_st" = "blocked" ]; then
return 2
fi
if [ "$agent_st" = "idle" ] || [ "$agent_st" = "done" ]; then
return 0
fi
local max_retries="$(mam_int_knob SKS_IDLE_RETRIES 2 0)"
local backoff_s="$(mam_int_knob SKS_IDLE_RETRY_BACKOFF_S 10 0)"
local try=0
while [ "$try" -le "$max_retries" ]; do
local watcher_out="" watcher_rc=0
watcher_out="$("$(_delegate_py_bin)" -m lib_py.loop.watcher wait "$target" \
--session "${HERDR_SESSION_NAME:-default}" \
--until idle --until done --until blocked \
--timeout-ms "$timeout_ms" 2>/dev/null)" || watcher_rc=$?
local parsed_st=""
if [ -n "$watcher_out" ]; then
parsed_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print(d.get("agent_status", "") or "")
except Exception:
print("")
' <<< "$watcher_out" 2>/dev/null || true)"
fi
if [ $watcher_rc -eq 0 ]; then
if [ "$parsed_st" = "blocked" ]; then
return 2
fi
return 0
elif [ $watcher_rc -eq 2 ]; then
if [ "$parsed_st" = "blocked" ]; then
return 2
fi
if [ "$try" -lt "$max_retries" ]; then
sleep "$backoff_s"
try=$((try + 1))
continue
else
return 1
fi
else
return 3
fi
done
return 1
}
# send_keys_safe <sess> <text> [job_id]
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
# 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. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission.
# An accepted submission whose status watch timed out is confirmed by polling
# for SKS_DELIVERY_GRACE_S (default 15 s) - see ISSUE-14 below.
# 4. Paste via unique buffer; verify the text landed (marker visible).
# 5. 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 pre_submit deadline try
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
local _sks_agent=""
case "$sess" in
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
*-opencode|*-opencode-[0-9]*|opencode|opencode-[0-9]*) _sks_agent="opencode" ;;
*-codex|*-codex-[0-9]*|codex|codex-[0-9]*) _sks_agent="codex" ;;
esac
if [ -n "$_sks_agent" ]; then
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
fi
fi
local _q_rc=0
_pane_quiescent "$sess" "$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
if [ "$_q_rc" = "1" ]; then
echo "send_keys_safe: pane never quiesced ($sess)" >&2
return 1
fi
if [ "$_q_rc" != "2" ]; then
deadline=$(( $(date +%s) + $(mam_int_knob SKS_DIALOG_TIMEOUT 30 0) ))
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
fi
local agent_target
agent_target=$(_sanitize_herdr_agent_name "$sess")
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
local b_deadline=$(( $(date +%s) + $(mam_int_knob SKS_BUSY_TIMEOUT 120 0) ))
local still_busy=1
while [ "$(date +%s)" -lt "$b_deadline" ]; do
local tail_pane
tail_pane=$(_pane_tail "$sess" 8)
if ! printf '%s\n' "$tail_pane" | grep -Fq "esc to interrupt" && ! printf '%s\n' "$tail_pane" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
still_busy=0
break
fi
sleep 1
done
if [ "$still_busy" -eq 1 ]; then
echo "send_keys_safe: pane still busy after SKS_BUSY_TIMEOUT ($sess)" >&2
return 4
fi
fi
# Native herdr 0.8+ fast path: agent prompt handles atomic submission
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
local p_out="" p_rc=0
local _p_tmp
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$")
if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then
rm -f "$_p_tmp" 2>/dev/null || true
return 0
fi
p_rc=$?
p_out=$(cat "$_p_tmp" 2>/dev/null || true)
rm -f "$_p_tmp" 2>/dev/null || true
if printf '%s\n' "$p_out" | grep -Fq "agent_not_found"; then
local _p_tmp2
_p_tmp2=$(mktemp 2>/dev/null || echo "/tmp/sks_p2_${sess}_$$")
if _sks_herdr agent prompt "$sess" "$text" --wait --until working --timeout 5000 >"$_p_tmp2" 2>&1; then
rm -f "$_p_tmp2" 2>/dev/null || true
return 0
fi
p_rc=$?
p_out=$(cat "$_p_tmp2" 2>/dev/null || true)
rm -f "$_p_tmp2" 2>/dev/null || true
fi
if printf '%s\n' "$p_out" | grep -Fq "agent_blocked"; then
echo "send_keys_safe: agent blocked ($sess)" >&2
return 2
fi
# Only fall through to legacy paste-buffer if agent prompt command itself is not supported
# or if target agent is not found (generic pane / terminal)
if printf '%s\n' "$p_out" | grep -Eq "unknown command|unrecognized command|no such command|unexpected argument|unrecognized option|agent_not_found"; then
: # Fall through to legacy paste buffer
else
# Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.),
# do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
sleep 2
if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then
return 0
fi
# ISSUE-14 / ISSUE-18: `timeout` / `agent_prompt_stalled` mean herdr
# ACCEPTED the submission and sent the input - only its post-submit
# status watch came up empty (codex under herdr 0.9.0 never reports
# `working`). For an accepted submission keep observing for
# SKS_DELIVERY_GRACE_S (default 15) and take a *protocol* signal only:
# agent_status working, or the job's own `started` in the registry
# (_sks_job_started). Pane changes and busy tokens are no longer
# delivery evidence (Issue #18: they are CLI-version-specific screen
# scraping that a headless or remote agent never produces). No signal
# still fails closed (rc 4); the wrapper then waits for `started` up to
# the job's ack timeout T before finalising the inject as failed.
if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then
local g_deadline=$(( $(date +%s) + $(mam_int_knob SKS_DELIVERY_GRACE_S 15 0) ))
while [ "$(date +%s)" -lt "$g_deadline" ]; do
[ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0
_sks_job_started "$job_id" && return 0
sleep 1
done
fi
echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
return 4
fi
else
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
fi
# Fallback: paste buffer submission. Compute verification markers on demand.
local marker marker_norm
# 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 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.
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
_sks_herdr set-buffer -b "$sks_buf" "$text"
local _paste_rc=0
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
if [ "$_paste_rc" != "0" ]; then
echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
return 3
fi
local was_popup=0
if [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]] || [[ "$sess" =~ "grok" ]]; 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: exclude busy tokens if already present in pre_submit
local pre_busy=0
if printf '%s\n' "$pre_submit" | grep -Fq "esc to interrupt" || printf '%s\n' "$pre_submit" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
pre_busy=1
fi
if [ "$pre_busy" -eq 0 ] && ( 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
}
# _mam_load_dialog_facts <agent>
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell, and the
# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller
# has not set them. `facts` is an eval contract (F8); evaluating all of it here
# would clobber tokens a caller deliberately set, so only these are taken, and
# the tokens only fill a gap. Without them the handler could never recognise a
# ready screen when called on its own - resume_session.sh does exactly that,
# with no wait_for_tui_ready before it - and idled through its whole timeout on
# every resume (review B-1). An agent the adapters do not know keeps an empty
# list and no tokens.
_mam_load_dialog_facts() {
local agent="$1" _f="" _d="" _s="" _r=""
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
return 0
fi
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f=""
_d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')"
_s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')"
_r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')"
if [ -n "$_d" ]; then
eval "MAM_STARTUP_DIALOGS=$_d"
else
MAM_STARTUP_DIALOGS='[]'
fi
if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then
eval "MAM_STRONG_READY_TOKENS=$_s"
fi
if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then
eval "MAM_READY_TOKENS=$_r"
fi
_MAM_DIALOGS_AGENT="$agent"
return 0
}
# _mam_dialog_pick — first actionable dialog on this frame.
# stdout: "send|<idx>|<key> <key>..." or "skip|<idx>|", nothing when there is
# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts).
_mam_dialog_pick() {
MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \
MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \
python3 -c '
import os, re, sys, json
pane = os.environ.get("MAM_DLG_PANE", "")
try:
dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]")
except Exception:
dialogs = []
auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"
try:
now = int(os.environ.get("MAM_DLG_NOW") or 0)
cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5)
except ValueError:
sys.exit(1)
state = {}
for tok in (os.environ.get("MAM_DLG_STATE") or "").split():
k, _, v = tok.partition(":")
try:
state[int(k)] = int(v)
except ValueError:
pass
for idx, entry in enumerate(dialogs):
try:
pattern, keys, kind = entry[0], entry[1], entry[2]
if not re.search(pattern, pane):
continue
except Exception:
continue
last = state.get(idx)
if last is not None and 0 <= now - last < cooldown:
# rate-limited: look at the other dialogs on this screen instead of
# giving up (merge review, codex probe: a skipped trust prompt or a
# freshly answered one used to hide the notice below it)
continue
if kind == "trust" and not auto_trust:
print("skip|%d|" % idx)
sys.exit(0)
print("send|%d|%s" % (idx, " ".join(keys)))
sys.exit(0)
sys.exit(1)
'
}
# handle_startup_dialogs <sess> [timeout_sec=20] [agent=claude]
# Answer the blocking modals an agent shows before its TUI is usable. The
# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS),
# so this is agent-agnostic - issue #24: it used to be a claude-only ladder
# called only for claude, and agy/grok/codex sat on their trust prompt until
# the readiness wait timed out and onboarding was skipped.
#
# `--source visible` keeps showing an accepted dialog for a while (#24
# comment), so a signature still on screen is NOT evidence that the keys were
# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S
# (default 5s) and acceptance is judged only by the ready tokens.
#
# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per
# cooldown); `notice` dialogs are always answered.
handle_startup_dialogs() {
local sess="$1" timeout="${2:-20}" agent="${3:-claude}"
local waited=0 pane pick action rest idx keys key now
local cooldown="${MAM_DIALOG_RESEND_S:-5}"
local auto_trust="${MAM_AUTO_TRUST:-1}"
_mam_load_dialog_facts "$agent"
local dialogs="${MAM_STARTUP_DIALOGS:-[]}"
local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}"
while [ "$waited" -lt "$timeout" ]; do
pane=$(_pane_tail "$sess" 20)
now=$(date +%s)
pick=""
pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true)
if [ -n "$pick" ]; then
action="${pick%%|*}"
rest="${pick#*|}"
idx="${rest%%|*}"
keys="${rest#*|}"
if [ "$action" = "send" ]; then
for key in $keys; do
_sks_herdr send-keys -t "$sess" "$key" || true
sleep 0.3
done
else
echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2
fi
_MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now"
elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then
return 0
fi
sleep 1
waited=$((waited + 1))
done
return 0
}
# Auto-initialize the herdr translation shim wrapper on library source
_init_herdr_isolation
# ---------------------------------------------------------------------------
# Issue #21 (item 3): control-plane binding of a session row. The logic lives
# in lib_py/control_plane.py; these are thin shell entry points.
# mam_control_plane_json <workspace> [<row control_plane JSON>]
mam_control_plane_json() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@"
}
# mam_control_plane_prefix <json>: the leading K=V tokens for new-session, or
# nothing when no forward is needed. rc 3: a forward is needed but a path
# contains whitespace (lib.sh splits the --env flags unquoted).
mam_control_plane_prefix() {
[ -n "${1:-}" ] || return 0
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
}