#!/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.8.8" 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_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}" if [ -z "${LOCAL_BIN:-}" ]; then if [ -x "$HOME_DIR/bin/herdr" ]; then LOCAL_BIN="$HOME_DIR/bin" elif [ -x "$HOME_DIR/.local/bin/herdr" ]; then LOCAL_BIN="$HOME_DIR/.local/bin" else LOCAL_BIN="$HOME/.local/bin" fi fi export HOME_DIR CLAUDE_PROJECT_DIR LOCAL_BIN # --------------------------------------------------------------------------- # Herdr Server Isolation support # --------------------------------------------------------------------------- # Paths to exclude when resolving the real herdr binary (shim/wrapper dirs). _HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}" _HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}" HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" _canonical_file() { local p="$1" t d b i=0 while [ -L "$p" ] && [ "$i" -lt 40 ]; do t="$(readlink "$p" 2>/dev/null)" || break case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac i=$((i + 1)) done d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1 b="$(basename "$p")" printf '%s/%s\n' "$d" "$b" } _is_shim_path() { case "/$1/" in *"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*) return 0 ;; esac return 1 } _resolve_real_herdr_path() { local dir cand save_ifs="$IFS" real_path="" IFS=: for dir in $PATH; do [ -n "$dir" ] || continue _is_shim_path "$dir" && continue [ -x "$dir/herdr" ] || continue cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr" [ -n "$cand" ] || cand="$dir/herdr" _is_shim_path "$cand" && continue real_path="$dir/herdr" break done IFS="$save_ifs" [ -n "$real_path" ] || return 1 printf '%s\n' "$real_path" } has_real_herdr() { _resolve_real_herdr_path >/dev/null 2>&1 } _init_herdr_isolation() { local wrapper_dir="$WORKSPACE_ROOT/.mam/shim" mkdir -p "$wrapper_dir" if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then export PATH="$wrapper_dir:$PATH" fi local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM" cat <<'EOF' > "$tmp_file" #!/usr/bin/env bash # Herdr-to-Herdr translation shim wrapper set -euo pipefail # 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 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 ` 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 .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 [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 [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 (workspace-scoped) # 2. herdr agent get (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 ... -- ` 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 ` 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 `. 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//herdr.sock -> # # (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 # # 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 # ":"; 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" ] } 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 ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" } _herdr() { mam_herdr "$@" } herdr() { mam_herdr "$@" } # --------------------------------------------------------------------------- # resolve_herdr_server # # 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 # # 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는 # lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다: # ① row['agent'] 명시 필드 # ② 세션명 접미사 (*-{creator,planner,reviewer}- 및 *-) # ③ pane.cmd (정확히 일치하거나 .../ 바이너리 경로) # 성공하면 에이전트명을 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 [workspace] # # returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row, # not a workspace label. Real isolation is `--session ` (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 [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 # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- mam_abs_workspace() { local p="${1:-}" ( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p" } # --------------------------------------------------------------------------- # mam_workspace_key # --------------------------------------------------------------------------- mam_workspace_key() { printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--' } # --------------------------------------------------------------------------- # derive_session_name [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 [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 [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 .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 [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 # # 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-SCOPED resolution of the resume UUID (P0-C). Resolution order: # 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id # (claude_session_id_own / agy_conversation_id_own) # 2) on-disk scan scoped to this workspace, via the agent adapter's discover() # Prints the UUID on stdout (empty line if none). Always exits 0. # --------------------------------------------------------------------------- find_workspace_uuid() { local workspace="$1" agent="$2" session_name="${3:-}" local abs; abs="$(mam_abs_workspace "$workspace")" MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' from lib_py.workspace_uuid import find_workspace_uuid_main find_workspace_uuid_main() PYEOF } # --------------------------------------------------------------------------- # capture_conversation_id [session_name] # # Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation # id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line # if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free # resolver (per-row own id -> workspace-scoped disk scan), # so recording its result into the row before kill guarantees tier-1 on the next # resume. Pass session_name to prefer that specific row's recorded id. Always exits 0. # --------------------------------------------------------------------------- capture_conversation_id() { local agent="$1" workdir="$2" session_name="${3:-}" find_workspace_uuid "$workdir" "$agent" "$session_name" } # --------------------------------------------------------------------------- # Session isolation — Universal Global Config (Option B, P3-1) # # Config-home isolation (.mam/agent_homes//) 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 # # Exits 0 if the row's status is 'stopped' (printing "stopped_at=" 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 — 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 — 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 # # 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 [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 [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 > "$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 # 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 # 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 [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 [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; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 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 — 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 [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: # # * /.events.log - the local publisher's own line, whose # data.dispatch_token must equal 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); # * //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 # given, a record counts only if it is dispatch_verified AND its # fingerprint equals SHA-256(). 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_started_recorded() { local job_id="$1" tok="${2:-}" logs_dir="${3:-}" local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}" [ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}" local ev_log="$reg_dir/$job_id.events.log" local 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 [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 deadline=$(( $(date +%s) + ack_s )) while :; do if delegate_started_recorded "$job_id" "$tok"; then return 0 fi if [ "$(date +%s)" -ge "$deadline" ]; then return 1 fi sleep 1 done } # ensure_subscriber_daemon [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 # 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 [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 [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 — 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 [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 [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 — 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 [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 [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 # Populate MAM_STARTUP_DIALOGS for , 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|| ..." or "skip||", 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 [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 [] mam_control_plane_json() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@" } # mam_control_plane_prefix : 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" }