#!/usr/bin/env bash # lib.sh — shared library for the multi-agent-mux-* skills. # # Single source of truth for the four things that were inconsistently # re-implemented across create/resume/delete/monitor (REVIEW.md §4.1): # - derive_session_name : the herdr session slug (P0-A) # - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B) # - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B) # - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C) # # Source it from each script with a path computed from the script location: # source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" # # HARD RULE: the agent-sessions.yaml file is only ever written through # atomic_dump_yaml. Never `open(yaml_path, 'w')` anywhere else. if [ -z "${BASH_VERSION:-}" ]; then echo "ERROR: lib.sh must be executed/sourced from bash (foreign shell detected)" >&2 # Reachable when this file is executed directly rather than sourced; # `return` only fails (falling through to `exit`) in that path. # shellcheck disable=SC2317 return 1 2>/dev/null || exit 1 fi SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" # Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then export PATH="$PATH:$dir" fi done # Central TUI dialog and readiness validation tokens (OP-6) _MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary' _MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects' # Workspace-relative defaults with environment overrides (Phase Z) HOME_DIR="${HOME_DIR:-$HOME}" CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}" 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 _REAL_HERDR_PATH="$real_path" export _REAL_HERDR_PATH printf '%s\n' "$real_path" } has_real_herdr() { _resolve_real_herdr_path >/dev/null 2>&1 } _init_herdr_isolation() { local wrapper_dir="$WORKSPACE_ROOT/.mam/shim" mkdir -p "$wrapper_dir" if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then export PATH="$wrapper_dir:$PATH" fi local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM" cat <<'EOF' > "$tmp_file" #!/usr/bin/env bash # Herdr-to-Herdr translation shim wrapper set -euo pipefail # Dynamic real herdr path resolution to prevent infinite recursion _resolve_real_herdr() { local dir save_ifs="$IFS" real_path="" IFS=: for dir in $PATH; do if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then real_path="$dir/herdr" break fi done IFS="$save_ifs" if [ -z "$real_path" ]; then real_path="herdr" fi echo "$real_path" } REAL_HERDR=$(_resolve_real_herdr) # Support parsing -L before the subcommand while [ "${1:-}" = "-L" ]; do if [ $# -lt 2 ]; then echo "herdr shim: -L requires an argument" >&2 exit 1 fi export HERDR_SESSION_NAME="$2" shift 2 done _MAM_SESSION="${HERDR_SESSION_NAME:-default}" if [ "$_MAM_SESSION" = "default" ]; then _MAM_SESSION="" else _mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c " import sys, json, os try: d = json.loads(sys.stdin.read()) name = os.environ.get('MAM_SESS', '') print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0') except Exception: print('0') " 2>/dev/null || echo "0") if [ "$_mam_session_running" != "1" ]; then # Headless bootstrap avoids herdr's "nested herdr is disabled" guard that # blocks a normal interactive `herdr --session ` launch from inside # an existing herdr pane (which is how MAM's own agents usually run). nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 & _mam_server_pid=$! disown 2>/dev/null || true for _mam_wait_i in $(seq 1 40); do [ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break kill -0 "$_mam_server_pid" 2>/dev/null || break sleep 0.25 done fi fi _real_herdr() { local session="${HERDR_SESSION_NAME:-}" if [ "$session" = "default" ]; then session="" fi if [ -n "$session" ]; then "$REAL_HERDR" --session "$session" "$@" else "$REAL_HERDR" "$@" fi } wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cmd="${1:-}" if [ -z "$cmd" ]; then echo "herdr shim: no command specified" >&2 exit 1 fi shift case "$cmd" in has-session) sess="" while [ $# -gt 0 ]; do case "$1" in -t) if [ $# -lt 2 ]; then echo "Error: -t requires a value" >&2 exit 1 fi sess="$2" shift 2 ;; *) shift ;; esac done _real_herdr agent get "$sess" >/dev/null ;; 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="cline" if echo "$name" | grep -qi "agy"; then kind="agy" elif echo "$name" | grep -qi "claude"; then kind="claude" elif echo "$name" | grep -qi "hermes"; then kind="hermes" elif echo "${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" fi # Strip duplicate agent binary name/path from final_cmd if present, preventing Go flag.Parse() positional argument interruption final_cmd=$(python3 -c " import sys, shlex cmd = sys.argv[1] kind = sys.argv[2] try: tokens = shlex.split(cmd) if tokens and (tokens[0] == kind or tokens[0].endswith('/' + kind)): if len(tokens) > 1 and not tokens[1].startswith('-'): tokens = tokens[1:] print(' '.join(shlex.quote(t) for t in tokens)) except Exception: print(cmd) " "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd") # W1: Resolve existing_ws by querying pane list for matching CWD (since WorkspaceInfo has no cwd key) existing_ws=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c " import sys, json, os target_ws = os.environ.get('TARGET_CWD', '') try: target_abs = os.path.realpath(target_ws) if target_ws else '' d = json.loads(sys.stdin.read()) panes = d.get('result', {}).get('panes', []) matched_ws = '' for p in panes: p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else '' if target_abs and p_cwd == target_abs and p.get('workspace_id'): matched_ws = p.get('workspace_id') break print(matched_ws) except Exception: pass " 2>/dev/null || echo "") ws_id="" split_arg="" if [ -n "$existing_ws" ]; then # W2a: Determine split direction policy via pane layout sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c " import sys, json, os target_ws = os.environ.get('TARGET_WS', '') try: d = json.loads(sys.stdin.read()) panes = d.get('result', {}).get('panes', []) for p in panes: if p.get('workspace_id') == target_ws and p.get('pane_id'): print(p.get('pane_id')) break except Exception: pass " 2>/dev/null || echo "") split_dir="" if [ -n "$sample_pane" ]; then split_dir=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null | MAM_MIN_COLS="${MAM_MIN_PANE_COLS:-60}" MAM_MIN_ROWS="${MAM_MIN_PANE_ROWS:-20}" python3 -c " import sys, json, os min_cols = int(os.environ.get('MAM_MIN_COLS', 60)) min_rows = int(os.environ.get('MAM_MIN_ROWS', 20)) try: d = json.loads(sys.stdin.read()).get('result', {}) focused_id = d.get('focused_pane_id', '') panes = d.get('panes', []) anchor = None for p in panes: if p.get('pane_id') == focused_id: anchor = p.get('rect', {}) break if not anchor and panes: anchor = panes[0].get('rect', {}) if anchor: w = anchor.get('width', 0) h = anchor.get('height', 0) if w // 2 >= min_cols: print('right') elif h // 2 >= min_rows: print('down') else: print('overflow') except Exception: pass " 2>/dev/null || echo "") fi if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then ws_id="$existing_ws" split_arg="--split $split_dir" elif [ "$split_dir" = "overflow" ]; then # W2b: Overflow threshold reached — force create fresh workspace existing_ws="" else ws_id="$existing_ws" split_arg="--split right" fi fi if [ -z "$existing_ws" ]; then ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 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', {}) w_obj = res.get('workspace', {}) print(w_obj.get('workspace_id') or res.get('workspace_id', '')) except Exception: pass " 2>/dev/null || echo "") 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) for i in $(seq 0 2); do if [ -n "$ws_id" ]; then res=$(eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true) else res=$(eval "_real_herdr agent start \"$name\" --cwd \"${ws:-.}\" $split_arg $env_flags -- $final_cmd" 2>&1 || true) fi if echo "$res" | grep -q "agent_started"; then success=1 break fi if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag"; then break fi if [ "$i" -lt 2 ]; then sleep "${backoffs[$i]}" fi done if [ "$success" -ne 1 ]; then echo "$res" >&2 exit 1 fi ;; kill-session) sess="" while [ $# -gt 0 ]; do case "$1" in -t) if [ $# -lt 2 ]; then echo "Error: -t requires a value" >&2 exit 1 fi sess="$2" shift 2 ;; *) shift ;; esac done # NOTE: herdr's `session` verb operates on whole server instances (see # `herdr session list`), not individual agent panes — `session stop/delete # "$sess"` with a MAM session name always fails (silently, via `|| true`). # Resolve the real pane_id via `agent get` and close just that pane instead. pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " import sys, json try: print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) except Exception: pass " 2>/dev/null) if [ -n "$pane_id" ]; then _real_herdr pane close "$pane_id" >/dev/null 2>&1 || true fi _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 `. info=$(_real_herdr agent get "$sess" 2>/dev/null || true) pane_id="" cwd="" cmd="" if [ -n "$info" ]; then parsed=$(python3 -c " import sys, json try: 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 _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true ;; send-keys) sess="" key="" while [ $# -gt 0 ]; do case "$1" in -t) if [ $# -lt 2 ]; then echo "Error: -t requires a value" >&2 exit 1 fi sess="$2" shift 2 ;; *) key="$1"; shift ;; esac done if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then key="Enter" fi # `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name — # resolve it via `agent get` first (agent-level commands accept names). pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " import sys, json try: print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) except Exception: pass " 2>/dev/null) if [ -n "$pane_id" ]; then _real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true else _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true fi ;; set-buffer) buf="tmp_buffer" text="" while [ $# -gt 0 ]; do case "$1" in -b) if [ $# -lt 2 ]; then echo "Error: -b requires a value" >&2 exit 1 fi buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')" if [ -z "$buf" ]; then echo "Error: -b name contains no usable characters" >&2 exit 1 fi buf="buf_$buf" shift 2 ;; *) text="$1"; shift ;; esac done # A-3 GC: unique buffer names mean an abandoned buffer (SIGINT/SIGTERM/ # timeout between set-buffer and delete-buffer) is never overwritten, so # it would leak forever. Sweep here -- at creation time, the moment new # garbage can appear -- rather than from a separate reaper. # # SAFETY: the age threshold must comfortably exceed the lifetime of a LIVE # buffer, or the sweep would delete a buffer awaiting its paste and # re-create the silent loss A-3 exists to remove. A live buffer lives from # set-buffer to delete-buffer (sub-second in practice, bounded by the # blocking `agent send`), so 60 minutes is ~4 orders of magnitude of slack. # Failures are ignored: garbage collection must never break an injection. # # The '.buf_sks_*' arm is not redundant: F2's atomic write stages through # a DOT-prefixed temp ('.buf_sks_....tmp'), which 'buf_sks_*' cannot match. # Without it a temp orphaned by SIGKILL between write and rename would leak # forever, exactly like the buffers this sweep exists to reclaim. buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}" buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}" mkdir -p "$buffer_dir" 2>/dev/null || true find "$buffer_dir" \( -name 'buf_sks_*' -o -name '.buf_sks_*' \) \ -mmin +${MAM_BUFFER_GC_MINUTES:-60} -delete 2>/dev/null || true _tmp="$buffer_dir/.$buf.$$.tmp" if ! { echo -n "$text" > "$_tmp" && mv -f "$_tmp" "$buffer_dir/$buf"; }; then rm -f "$_tmp" echo "Error: failed to write buffer $buf" >&2 exit 1 fi ;; paste-buffer) buf="tmp_buffer" sess="" while [ $# -gt 0 ]; do case "$1" in -b) buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')" if [ -z "$buf" ]; then echo "Error: -b name contains no usable characters" >&2 exit 1 fi buf="buf_$buf" shift 2 ;; -t) if [ $# -lt 2 ]; then echo "Error: -t requires a value" >&2 exit 1 fi sess="$2" shift 2 ;; *) shift ;; esac done buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}" buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}" if [ -f "$buffer_dir/$buf" ]; then _real_herdr agent send "$sess" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1 || true else echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2 exit 1 fi ;; delete-buffer) buf="tmp_buffer" while [ $# -gt 0 ]; do case "$1" in -b) buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')" if [ -z "$buf" ]; then echo "Error: -b name contains no usable characters" >&2 exit 1 fi buf="buf_$buf" shift 2 ;; *) shift ;; esac done buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}" buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}" rm -f "$buffer_dir/$buf" ;; ls) format="" while [ $# -gt 0 ]; do case "$1" in -F) if [ $# -lt 2 ]; then echo "Error: -F requires a value" >&2 exit 1 fi format="$2" shift 2 ;; *) shift ;; esac done # herdr has no session-creation timestamp: `agent list`, `agent get`, # `pane get` and `api snapshot` all lack one. Derive it from the OS as the # start time of the pane's ROOT process (`shell_pid`). # # It must NOT come from `foreground_processes[0]`: under MAM that slot # holds the `caffeinate -i -t 300` keep-awake wrapper, which respawns every # five minutes. Its start time creeps forward, so a session idle for longer # than one caffeinate cycle would look "created after" its own transcript # and be discarded by the stale-transcript guard in verify_session_uuid. # # This runs as ONE python process that re-implements `_real_herdr` and # batches a single `ps` over every pid, rather than forking python and ps # once per agent inside a shell loop: reconcile calls this on a 15s # heartbeat, so per-agent forking shows up as a standing cost. # # TZ=UTC pins both `ps` (which prints lstart in the caller's zone) and # `mktime` (which reads it back in the caller's zone) to the same zone. # They already agree at any single TZ, so this is not about skew between # them -- it removes the once-a-year DST ambiguity in local-time mktime. # LC_ALL=C pins lstart's field names, which are locale-dependent. _sess="${HERDR_SESSION_NAME:-}" if [ "$_sess" = "default" ]; then _sess="" fi _real_herdr agent list 2>/dev/null | \ MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \ TZ=UTC LC_ALL=C python3 -c ' import json, os, subprocess, sys, time fmt = os.environ.get("MAM_LS_FORMAT", "") real = os.environ.get("MAM_REAL_HERDR") or "herdr" sess = os.environ.get("MAM_LS_SESSION") or "" base = [real] + (["--session", sess] if sess else []) raw = sys.stdin.read() try: agents = json.loads(raw).get("result", {}).get("agents", []) or [] except Exception: # Unparseable means "we do not know", not "there are no sessions". # Exiting nonzero lets reconcile fall into its herdr_confirmed=False path # instead of reading empty stdout as a confirmed zero and terminating # every live row it has on file. sys.exit(1) rows = [] for a in agents: name = a.get("name") or a.get("agent") or "unknown" pane = a.get("pane_id") or "" pid = None if pane: try: out = subprocess.run(base + ["pane", "process-info", "--pane", pane], capture_output=True, text=True, timeout=10).stdout pi = json.loads(out).get("result", {}).get("process_info", {}) p = pi.get("shell_pid") or pi.get("foreground_process_group_id") if isinstance(p, int) and p > 0: pid = p except Exception: pass rows.append((name, pid)) starts = {} pids = sorted({p for _, p in rows if p}) if pids: try: out = subprocess.run(["ps", "-o", "pid=,lstart=", "-p", ",".join(str(p) for p in pids)], capture_output=True, text=True, timeout=10).stdout for line in out.splitlines(): line = line.strip() if not line: continue head, _, rest = line.partition(" ") try: starts[int(head)] = int(time.mktime(time.strptime(rest.strip(), "%a %b %d %H:%M:%S %Y"))) except Exception: pass except Exception: pass # Degrade to now, never to 0 or a sentinel. The consumer guard is # if epoch and mtime(transcript) < epoch: reject # so an over-estimate only makes it stricter, while an under-estimate # (0 is falsy; 999999 is 1970-01-12 and below every real mtime) switches # the guard off outright -- which is the B-4 defect. now = int(time.time()) for name, pid in rows: if fmt == "#{session_name}": print(name) else: print(name + "|" + str(starts.get(pid) or now)) ' ;; *) _real_herdr "$cmd" "$@" ;; esac EOF chmod +x "$tmp_file" 2>/dev/null || true mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then export PATH="$wrapper_dir:$PATH" fi } mam_herdr() { _init_herdr_isolation "$WORKSPACE_ROOT/.mam/shim/herdr" "$@" } _herdr() { mam_herdr "$@" } herdr() { mam_herdr "$@" } # --------------------------------------------------------------------------- # resolve_herdr_server # # 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. # --------------------------------------------------------------------------- load_state_json() { env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, sys, sqlite3, json, yaml yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' d = {} db_sessions = [] try: if os.path.exists(db_path): conn = sqlite3.connect(db_path, timeout=60.0) conn.execute('PRAGMA busy_timeout = 60000') try: row = conn.execute('SELECT data FROM state WHERE id=1').fetchone() if row: d = json.loads(row[0]) except sqlite3.OperationalError: pass try: cursor = conn.execute('SELECT data FROM sessions') for r in cursor.fetchall(): db_sessions.append(json.loads(r[0])) d['herdr_sessions'] = db_sessions except sqlite3.OperationalError: pass conn.close() elif os.path.exists(yaml_path): with open(yaml_path) as f: d = yaml.safe_load(f) or {} except Exception: pass # Clean lone surrogates to prevent UnicodeEncodeError on stdout print def clean_surrogates(obj): if isinstance(obj, str): return obj.encode('utf-8', errors='replace').decode('utf-8') elif isinstance(obj, dict): return {k: clean_surrogates(v) for k, v in obj.items()} elif isinstance(obj, list): return [clean_surrogates(x) for x in obj] return obj d = clean_surrogates(d) print(json.dumps(d, ensure_ascii=False)) PYEOF } # Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed # all do `HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this # returns the isolated herdr *session* name to use for this MAM session row, not # a workspace id. Real isolation is `--session ` (see `_MAM_SESSION` in the # generated wrapper) — a workspace label match provides no actual isolation # since agent/pane commands are server-global regardless of workspace. resolve_herdr_session() { local session_name="$1" local workspace="${2:-}" MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c " import sys, os, json name = os.environ['SESSION_NAME'] ws = os.environ.get('TARGET_WS', '').strip() d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) for s in d.get('herdr_sessions', []): if s.get('name') == name: val = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') if val and val != 'default': print(val) sys.exit(0) fallback = '' if ws: abs_ws = os.path.abspath(ws) parent = os.path.basename(os.path.dirname(abs_ws)) or 'workspace' work = os.path.basename(abs_ws) or 'root' if parent in ('/', '.'): parent = 'workspace' if work in ('/', '.'): work = 'root' slug = f'{parent}-{work}'.lower().replace('_', '-') import re slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lstrip('-') fallback = f'mam-{slug}' if slug else 'mam-ws' else: fallback = os.environ.get('HERDR_SESSION_NAME', '') or os.environ.get('HERDR_SERVER_NAME', '') if not fallback or fallback == 'default': fallback = 'default' print('WARN: resolve_herdr_session called without workspace parameter for unregistered session; falling back to default', file=sys.stderr) print(fallback or 'default') " } resolve_herdr_workspace() { resolve_herdr_session "$@" } # --------------------------------------------------------------------------- # derive_workspace_slug # --------------------------------------------------------------------------- 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 '/_' '--' } # --------------------------------------------------------------------------- # mam_session_iso_root # --------------------------------------------------------------------------- mam_session_iso_root() { MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import json, os name = os.environ.get('MAM_ISO_SESSION', '') try: d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) except Exception: d = {} for s in (d.get('herdr_sessions') or []): if s.get('name') == name: iso = s.get('isolation') if isinstance(iso, dict) and iso.get('root'): print(iso['root']) break PYEOF } # --------------------------------------------------------------------------- # derive_session_name # --------------------------------------------------------------------------- derive_session_name() { local workspace="${1:-$PWD}" agent="${2:-}" local base_slug base_slug="$(derive_workspace_slug "$workspace")" local slug="${base_slug#mam-}" if [ -n "$agent" ]; then printf '%s-creator-%s' "$slug" "$agent" else printf '%s-creator-' "$slug" 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") 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") 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' import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl from datetime import datetime, timezone import yaml yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' def _validate(d): if not isinstance(d, dict): raise SystemExit("VALIDATE: top-level is not a mapping") sessions = d.get('herdr_sessions', []) if not isinstance(sessions, list): raise SystemExit("VALIDATE: herdr_sessions is not a list") valid = {'running', 'terminated', 'archived', 'stopped'} for i, s in enumerate(sessions): if not isinstance(s, dict): raise SystemExit(f"VALIDATE: herdr_sessions[{i}] not a mapping") if not s.get('name') or not s.get('status'): raise SystemExit(f"VALIDATE: herdr_sessions[{i}] missing name/status") if s.get('role') is not None and (not isinstance(s['role'], str) or not s['role'].strip()): raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} role must be a non-empty string") if s['status'] not in valid: raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}") if not isinstance(s.get('pane'), dict): raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane") iso = s.get('isolation') if iso is not None: if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'): raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root") orc_uuids = d.get('orchestrator_uuids') if orc_uuids is not None: if not isinstance(orc_uuids, list): raise SystemExit("VALIDATE: orchestrator_uuids is not a list") seen_orc = set() for u in orc_uuids: if not isinstance(u, str) or not u.strip(): raise SystemExit("VALIDATE: orchestrator_uuids items must be non-empty strings") if u in seen_orc: raise SystemExit("VALIDATE: orchestrator_uuids contains duplicate item") seen_orc.add(u) def get_terminal_set(d): return {s.get('name'): s.get('status') for s in d.get('herdr_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')} def get_all_sessions_status(d): import hashlib res = {} for s in d.get('herdr_sessions', []): name = s.get('name') ser = json.dumps(s, sort_keys=True) res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest() orc_uuids = d.get('orchestrator_uuids') if orc_uuids is not None: ser_orc = json.dumps(orc_uuids, sort_keys=True) res['__orchestrator_uuids__'] = hashlib.sha256(ser_orc.encode('utf-8')).hexdigest() return res os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True) _flock_f = open(db_path + '.lock', 'w') fcntl.flock(_flock_f, fcntl.LOCK_EX) conn = sqlite3.connect(db_path, timeout=60.0) conn.execute('PRAGMA busy_timeout = 60000') for f in [db_path, db_path + '-wal', db_path + '-shm']: if os.path.exists(f): try: os.chmod(f, 0o600) except Exception: pass is_nfs = os.environ.get('MAM_IS_NFS') == 'true' if is_nfs: conn.execute('PRAGMA journal_mode=DELETE') else: conn.execute('PRAGMA journal_mode=WAL') try: # Disable auto-commit by explicitly starting a transaction with BEGIN IMMEDIATE # This prevents the read-modify-write lost update race condition. for _beg_attempt in range(300): try: conn.execute('BEGIN IMMEDIATE') break except sqlite3.OperationalError: import time time.sleep(0.1) else: conn.execute('BEGIN IMMEDIATE') conn.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, data TEXT)') conn.execute('CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)') conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_pane_cwd ON sessions(pane_cwd)') row = conn.execute('SELECT data FROM state WHERE id=1').fetchone() if row: d = json.loads(row[0]) else: # Seed from YAML if os.path.exists(yaml_path): with open(yaml_path) as f: d = yaml.safe_load(f) or {} else: d = {} # Assemble d['herdr_sessions'] from sessions table if table contains data db_sessions = [] cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions') for s_row in cursor.fetchall(): s_data = json.loads(s_row[3]) s_data['name'] = s_row[0] s_data['status'] = s_row[1] if 'pane' not in s_data: s_data['pane'] = {} s_data['pane']['cwd'] = s_row[2] db_sessions.append(s_data) if db_sessions: d['herdr_sessions'] = db_sessions elif 'herdr_sessions' not in d: d['herdr_sessions'] = [] old_sessions = get_all_sessions_status(d) old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')} # --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) --- exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '', 'exec'), globals()) # Role immutability check for s in d.get('herdr_sessions', []): name = s.get('name') if name in old_roles and s.get('role') != old_roles[name]: raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}") # ID Uniqueness Check (T2) running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own'] id_to_session = {} for s in d.get('herdr_sessions', []): if s.get('status') == 'running': s_name = s.get('name') for k in running_keys: v = s.get(k) if v: if v in id_to_session and id_to_session[v] != s_name: raise SystemExit(f"VALIDATE: Duplicate running conversation ID {v!r} detected between {id_to_session[v]!r} and {s_name!r}") id_to_session[v] = s_name _validate(d) # Separate globals and sessions for normalization d_state = {k: v for k, v in d.items() if k != 'herdr_sessions'} conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),)) current_names = [] for s in d.get('herdr_sessions', []): name = s.get('name') status = s.get('status') pane_cwd = (s.get('pane') or {}).get('cwd', '') conn.execute('REPLACE INTO sessions (name, status, pane_cwd, data) VALUES (?, ?, ?, ?)', (name, status, pane_cwd, json.dumps(s))) current_names.append(name) if current_names: placeholders = ','.join('?' for _ in current_names) conn.execute(f'DELETE FROM sessions WHERE name NOT IN ({placeholders})', current_names) else: conn.execute('DELETE FROM sessions') new_sessions = get_all_sessions_status(d) conn.commit() # Write to YAML when sessions status or session list changes (e.g. create/stop/resume) if new_sessions != old_sessions: if os.path.exists(yaml_path): try: shutil.copy2(yaml_path, yaml_path + '.bak') except Exception: pass dir_ = os.path.dirname(yaml_path) or '.' fd, tmp = tempfile.mkstemp(dir=dir_, prefix='.agent-sessions.', suffix='.tmp') try: with os.fdopen(fd, 'w') as f: yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False, allow_unicode=True, width=4096) os.replace(tmp, yaml_path) except Exception: if os.path.exists(tmp): os.remove(tmp) raise try: conn.execute('PRAGMA wal_checkpoint(TRUNCATE)') except Exception: pass except Exception: conn.rollback() raise finally: conn.close() # H3: Re-apply chmod 0600 after close to cover newly created -wal / -shm files try: os.chmod(db_path, 0o600) wal = db_path + '-wal' if os.path.exists(wal): os.chmod(wal, 0o600) shm = db_path + '-shm' if os.path.exists(shm): os.chmod(shm, 0o600) except Exception: pass try: fcntl.flock(_flock_f, fcntl.LOCK_UN) _flock_f.close() except Exception: pass PYEOF } # --------------------------------------------------------------------------- # verify_session_uuid [session_row_json] [mode] # Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise. # --------------------------------------------------------------------------- VERIFY_SESSION_PYTHON=' _MAM_ORC_CACHE = None def mam_orchestrator_uuids(): global _MAM_ORC_CACHE if _MAM_ORC_CACHE is not None: return _MAM_ORC_CACHE import os, sys, json, sqlite3, yaml override = os.environ.get("MAM_ORCHESTRATOR_UUIDS") if override is not None: if not override.strip(): _MAM_ORC_CACHE = [] return _MAM_ORC_CACHE if override.strip().startswith("["): try: parsed = json.loads(override) if isinstance(parsed, list): _MAM_ORC_CACHE = [str(x) for x in parsed if x] return _MAM_ORC_CACHE except Exception: pass _MAM_ORC_CACHE = [x.strip() for x in override.split(",") if x.strip()] return _MAM_ORC_CACHE res = [] d_obj = None try: if "MAM_STATE_JSON" in os.environ and os.environ.get("MAM_STATE_JSON"): d_obj = json.loads(os.environ.get("MAM_STATE_JSON")) except Exception: d_obj = None if d_obj is None or "orchestrator_uuids" not in d_obj: yaml_p = os.environ.get("AGENT_SESSIONS_YAML") or os.environ.get("YAML_PATH") if yaml_p: db_p = os.path.splitext(yaml_p)[0] + ".db" if os.path.exists(db_p): try: conn = sqlite3.connect(db_p, timeout=5.0) r = conn.execute("SELECT data FROM state WHERE id=1").fetchone() if r and r[0]: d_obj = json.loads(r[0]) conn.close() except Exception: pass if (d_obj is None or "orchestrator_uuids" not in d_obj) and os.path.exists(yaml_p): try: with open(yaml_p) as f: d_obj = yaml.safe_load(f) or {} except Exception: pass raw = d_obj.get("orchestrator_uuids") if isinstance(d_obj, dict) else None if raw is not None: if isinstance(raw, list) and all(isinstance(x, str) and x.strip() for x in raw): res = list(raw) else: sys.stderr.write("WARNING: malformed orchestrator_uuids in state, disabling orchestrator exclusion gate\n") res = [] _MAM_ORC_CACHE = res return _MAM_ORC_CACHE def mam_row_own_uuid(row): if not isinstance(row, dict): return None for k in ["claude_session_id_own", "agy_conversation_id_own", "hermes_conversation_id_own", "cline_conversation_id_own"]: v = row.get(k) if v: return v return None def workspace_key(path): import os try: p = os.path.realpath(path) except Exception: p = path return p.replace("/", "-").replace("_", "-") def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"): import os, json, sqlite3 home = home_dir or os.environ.get("HOME_DIR", "") c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects") row = row or {} _iso = row.get("isolation") iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None # The floor answers "could this transcript belong to a PREVIOUS incarnation # of this session?", which only matters while picking an unknown uuid off # disk. In "revalidate" the uuid is one this row already recorded, and a # session resumed but not yet written to legitimately has a transcript # older than its current process -- applying the floor there discards the # very conversation the resume was for. epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0 cwd = row.get("pane", {}).get("cwd", "") or ws # ORDERING INVARIANT: the workspace check below MUST run before the # assigned-id shortcut. Moving the shortcut above it would return True for a # row belonging to a different workspace purely because it is assigned and # unverified, breaking the one guarantee find_workspace_uuid exists to give # -- never hand back an id that belongs to a different workspace. (T-12) if workspace_key(cwd) != workspace_key(ws): return False if mode == "discover" and uuid in mam_orchestrator_uuids(): if uuid != mam_row_own_uuid(row): return False if (mode == "revalidate" and row.get("session_id_source") == "assigned" and not row.get("session_id_verified")): return True if agent == "claude": base = (iso_root + "/projects") if iso_root else c_dir key = workspace_key(ws) path = f"{base}/{key}/{uuid}.jsonl" if not os.path.exists(path): return False if epoch and os.path.getmtime(path) < epoch: return False try: valid_session = False found_cwd = None with open(path) as f: for _ in range(50): line = f.readline() if not line: break line = line.strip() if not line: continue try: payload = json.loads(line) if payload.get("sessionId") == uuid: valid_session = True if payload.get("cwd"): found_cwd = payload.get("cwd") break except Exception: pass if not valid_session: return False if found_cwd and workspace_key(found_cwd) != workspace_key(cwd): return False except Exception: return False elif agent == "agy": base = f"{iso_root or home}/.gemini/antigravity-cli/conversations" path = f"{base}/{uuid}.db" if not os.path.exists(path): return False if epoch and os.path.getmtime(path) < epoch: return False if mode == "discover": lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json" cache_match = False if os.path.exists(lc): try: with open(lc) as f: lc_data = json.load(f) cache_match = (lc_data.get(cwd) == uuid) except Exception: cache_match = False if not cache_match: if uuid in (row.get("_sibling_claimed_uuids") or []): return False try: conn = sqlite3.connect(path) r = conn.execute("SELECT count(*) FROM steps").fetchone() conn.close() if not r or r[0] < 1: return False except Exception: return False elif agent == "hermes": hdb = f"{iso_root or home}/.hermes/state.db" if not os.path.exists(hdb): return False if epoch and os.path.getmtime(hdb) < epoch: return False try: conn = sqlite3.connect(hdb) r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone() conn.close() if not r: return False except Exception: return False elif agent == "cline": base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions" path = f"{base}/{uuid}/{uuid}.json" if not os.path.exists(path): return False if epoch and os.path.getmtime(path) < epoch: return False try: with open(path) as f: sdata = json.load(f) if sdata.get("session_id") != uuid: return False except Exception: return False return True ' verify_session_uuid() { local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, sys, json exec(os.environ['MAM_VERIFY_PY']) ws = os.environ['WS_ABS'] agent = os.environ['AGENT'] uuid = os.environ['UUID'] row_str = os.environ.get('ROW_JSON', '') row = json.loads(row_str) if row_str else {} mode = os.environ.get('MODE', 'discover') if verify_session_uuid(ws, agent, uuid, row, mode=mode): sys.exit(0) sys.exit(1) PYEOF } # verify_tui_viewport # # 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). It NEVER returns a # global agent_identities id unless that id's project_cwd matches THIS # workspace. Resolution order: # 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id # (claude_session_id_own / agy_conversation_id_own) # 2) on-disk scan scoped to this workspace # (claude: ~/.claude/projects//*.jsonl ; agy: last_conversations.json[cwd]) # 3) agent_identities cache in d (primary) or $YAML_PATH (fallback), ONLY when its project_cwd == this workspace. # (Note: DB is authority, YAML is mirror; tier-3 never prioritizes mirror over DB) # 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" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, sys, json, glob, sqlite3 ws = os.environ['WS_ABS'] agent = os.environ['AGENT'] home = os.environ['HOME_DIR'] claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") target = os.environ.get('TARGET_SESSION', '') exec(os.environ['MAM_VERIFY_PY']) OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own', 'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'} def iso_root_of(s): iso = s.get('isolation') return iso.get('root') if isinstance(iso, dict) else None # Ingest the merged state dictionary from stdin pipeline try: d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) except Exception: d = {} running_ids = set() for s_item in d.get('herdr_sessions', []): if s_item.get('status') == 'running': if target and s_item.get('name') == target: continue for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']: val = s_item.get(k) if val: running_ids.add(val) orchestrator_ids = set(mam_orchestrator_uuids()) if target: for s_item in d.get('herdr_sessions', []): if s_item.get('name') == target: own = mam_row_own_uuid(s_item) if own: orchestrator_ids.discard(own) def emit(u): if u in running_ids or u in orchestrator_ids: return print(u) raise SystemExit(0) # Fetch all sessions matching this workspace sessions = [] for s in d.get('herdr_sessions', []): s_cwd = (s.get('pane') or {}).get('cwd', '') if isinstance(s, dict) else '' if s_cwd and (s_cwd == ws or os.path.realpath(s_cwd) == os.path.realpath(ws)): sessions.append(s) if target: for s in sessions: if s.get('name') != target: continue iso = iso_root_of(s) cand = s.get(OWN_KEY.get(agent, ''), None) if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if iso: key = workspace_key(ws) if agent == 'claude': for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True): cand = os.path.basename(j)[:-6] if cand and verify_session_uuid(ws, agent, cand, s): emit(cand) elif agent == 'agy': for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True): cand = os.path.basename(j)[:-3] if cand and verify_session_uuid(ws, agent, cand, s): emit(cand) elif agent == 'hermes': hdb = f"{iso}/.hermes/state.db" if os.path.exists(hdb): try: conn = sqlite3.connect(hdb) r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone() conn.close() if r: cand = r[0] if verify_session_uuid(ws, agent, cand, s): emit(cand) except Exception: pass elif agent == 'cline': for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True): fn = os.path.basename(folder) if os.path.exists(f"{folder}/{fn}.json"): if verify_session_uuid(ws, agent, fn, s): emit(fn) print('') raise SystemExit(0) for s in sessions: name = s.get('name', '') if agent == 'claude' and name.endswith('-creator-claude'): cand = s.get('claude_session_id_own') if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'agy' and name.endswith('-creator-agy'): cand = s.get('agy_conversation_id_own') if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'hermes' and name.endswith('-creator-hermes'): cand = s.get('hermes_conversation_id_own') if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'cline' and name.endswith('-creator-cline'): cand = s.get('cline_conversation_id_own') if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'claude': key = workspace_key(ws) proj = f"{claude_project_dir}/{key}" if os.path.isdir(proj): for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True): cand = os.path.basename(j)[:-6] if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'agy': lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json" if os.path.exists(lc): cand = None try: cand = json.load(open(lc)).get(ws) except Exception: cand = None if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'hermes': hdb = f"{home}/.hermes/state.db" if os.path.exists(hdb): cand = None try: conn = sqlite3.connect(hdb) r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone() conn.close() if r: cand = r[0] except Exception: cand = None if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'cline': sessions_dir = f"{home}/.cline/data/sessions" if os.path.isdir(sessions_dir): candidates = [] for session_folder in glob.glob(f"{sessions_dir}/*"): if os.path.isdir(session_folder): folder_name = os.path.basename(session_folder) json_file = f"{session_folder}/{folder_name}.json" if os.path.exists(json_file): candidates.append(json_file) candidates.sort(key=os.path.getmtime, reverse=True) for j in candidates: cand = os.path.basename(j)[:-5] if cand and verify_session_uuid(ws, agent, cand): emit(cand) ai = d.get('agent_identities') if isinstance(d, dict) else None if not isinstance(ai, dict) or not ai: ai = {} try: yaml_path = os.environ['YAML_PATH'] db_path = os.path.splitext(yaml_path)[0] + '.db' 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: ai = json.loads(row[0]).get('agent_identities') or {} except sqlite3.OperationalError: pass conn.close() elif os.path.exists(yaml_path): import yaml with open(yaml_path) as f: _ydoc = yaml.safe_load(f) or {} ai = _ydoc.get('agent_identities') or {} except Exception as e: print(f"WARN: tier-3 identity lookup failed: {e}", file=sys.stderr) if not isinstance(ai, dict): ai = {} ai_agent = ai.get(agent) or {} if ai_agent.get('project_cwd') == ws: if agent == 'claude': cand = ai_agent.get('session_id') if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'agy': cand = ai_agent.get('conversation_id') if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'hermes': cand = ai_agent.get('session_id') or ai_agent.get('conversation_id') if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'cline': cand = ai_agent.get('session_id') or ai_agent.get('conversation_id') if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) print('') 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, 3-tier, race-free # resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache), # so recording its result into the row before kill guarantees tier-1 on the next # resume. Pass session_name to scope resolution to that row (required for # isolated sessions — see isolation block / T5). Always exits 0. # --------------------------------------------------------------------------- capture_conversation_id() { local agent="$1" workdir="$2" session_name="${3:-}" find_workspace_uuid "$workdir" "$agent" "$session_name" } # --------------------------------------------------------------------------- # Session isolation — Universal Global Config (Option A, Job 536a6625) # # Config-home isolation (.mam/agent_homes//) was removed in favor of: # 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini, # ~/.hermes, ~/.cline user configuration and credential stores. # 2. Process Isolation: each agent-workspace pair runs in its own herdr pane. # 3. Conversation Isolation: session UUIDs discriminate conversation history. # # Stubbed isolation functions kept for backward compatibility: # --------------------------------------------------------------------------- provision_isolation() { local agent="$1" root="$2" printf '' } isolation_lever() { case "$1" in claude|agy|hermes|cline) echo "none" ;; *) echo "" ;; esac } isolation_env_prefix() { : } isolation_cmd_args() { : } # --------------------------------------------------------------------------- # is_already_stopped # # 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_submit_job # # Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout. delegate_submit_job() { local prompt="$1" agent="$2" session="$3" local py_bin registry_py py_bin="$(_delegate_py_bin)" registry_py="$(_delegate_script registry.py)" if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2 return 1 fi "$py_bin" "$registry_py" register \ --prompt "$prompt" \ --agent "$agent" \ --agent-session "$session" } # delegate_publish_event [detail] # # Publish a lifecycle event to the multi-agent-mux-delegate-job registry. Consolidates the # inline .venv-walk + publish_event.py blocks that were duplicated across # create/delete/resume (review item 7). Non-fatal by contract: an empty job id, # a missing script, or a broker failure never aborts the caller. delegate_publish_event() { local job_id="$1" event="$2" detail="${3:-}" [ -n "$job_id" ] || return 0 local py_bin pub py_bin="$(_delegate_py_bin)" pub="$(_delegate_script publish_event.py)" [ -n "$pub" ] && [ -f "$pub" ] || return 0 "$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true } # start_watchdog [workdir] # Spawns a watchdog process to monitor a delegate-job JOB in the background. # The watchdog re-spawns the subscriber every 2 minutes (or whatever hard # limit we set) and exits automatically when the JOB reaches terminal state. # Returns the watchdog PID via stdout. start_watchdog() { local job_id="$1" local workdir="${2:-$PWD}" local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" local log_file="$workdir/.mam/multi-agent-mux-monitor.log" if [ ! -f "$monitor_script" ]; then echo "ERROR: monitor script not found: $monitor_script" >&2 return 1 fi # Check if reconcile.sh --subscribe is already running on this workspace local pid pid=$(pgrep -f "bash $monitor_script --subscribe" || true) if [ -z "$pid" ]; then # Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out) # and ensure it runs with $workdir as cwd to anchor relative log paths. local orig_pwd="$PWD" cd "$workdir" || return 1 nohup bash "$monitor_script" --subscribe --idle-timeout 0 > "$log_file" 2>&1 & pid=$! cd "$orig_pwd" || return 1 fi echo "$pid" } # wait_for_tui_ready # Waits up to 15 seconds for the agent's TUI to render its welcome screen. wait_for_tui_ready() { local sess="$1" agent="$2" local i for i in {1..30}; do if _pane_dialog_open "$sess"; then if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then _sks_herdr send-keys -t "$sess" Enter || true sleep 1 fi sleep 1 continue fi local content content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "") if [ -n "$content" ]; then case "$agent" in claude) if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then echo "✅ Claude TUI detected ready." return 0 fi ;; agy) if echo "$content" | grep -q "Antigravity" 2>/dev/null; then echo "✅ Antigravity TUI detected ready." return 0 fi ;; hermes) if echo "$content" | grep -q "Hermes" 2>/dev/null; then echo "✅ Hermes TUI detected ready." return 0 fi ;; cline) if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then echo "✅ Cline TUI detected ready." return 0 fi ;; esac fi sleep 1 done echo "⚠️ TUI readiness check timed out for '$sess'." >&2 return 1 } # inject_instructions [job_id] # Injects instructions into the herdr session using paste-buffer and C-m. # Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5). inject_instructions() { send_keys_safe "$1" "$2" "${3:-onboard}" } # --------------------------------------------------------------------------- # Prompt-lock safe delivery (FW-W2). Keys are sent on evidence, not timers. # send_keys_safe returns 0 only if the text was verifiably submitted. # Exit codes: 1=pane never quiesced 2=dialog blocking input # 3=paste not visible 4=Enter not accepted # Callers MUST handle non-zero. # --------------------------------------------------------------------------- # Server-aware herdr (same isolation rule as inject_instructions). _sks_herdr() { mam_herdr "$@" } _pane_capture() { local raw raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")" if [ -z "$raw" ]; then echo "" return 0 fi python3 -c ' import sys, json raw = sys.stdin.read() try: data = json.loads(raw) text = data.get("result", {}).get("read", {}).get("text") if text is not None: print(text, end="") else: print(raw, end="") except Exception: print(raw, end="") ' <<< "$raw" } # Bottom-N *content* lines: capture-pane -p pads the viewport with trailing # blank rows; window on non-blank lines or top-anchored dialogs are invisible. _pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; } # _wait_session_gone [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] # Renderer settled = two consecutive identical non-empty captures. # Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep. _pane_quiescent() { local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i for ((i = 0; i < tries; i++)); do cur=$(_pane_capture "$sess") [ -z "$cur" ] && { sleep "$interval"; continue; } [ "$cur" = "$prev" ] && return 0 prev="$cur" sleep "$interval" done return 1 } # _pane_dialog_open — focus-stealing modal signatures (trust / # permission / OAuth / list-selection), checked in the bottom 20 pane lines # only (dialogs render near the input area; conversation text above must not # trigger this). Tokens must NOT appear on normal idle prompt screens. _pane_dialog_open() { _pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS" } # send_keys_safe [job_id] # 1. Wait for renderer quiescence (RC-A). # 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to # SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single # Escape per poll and re-check. NEVER a blind Enter. # 3. Paste via unique buffer; verify the text landed (marker visible). # 4. Submit C-m; verify submission (marker left the input area AND the pane # changed); retry up to 3 times. send_keys_safe() { local sess="$1" text="$2" job_id="${3:-adhoc}" local marker pre_submit deadline try # Verification token: last 24 *characters* (not bytes — `tail -c` can split a # multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match # the properly-decoded rendered pane text) of the last non-empty line. marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')") # Whitespace-normalized copy for matching: some TUIs (e.g. cline's own # message rendering) don't just soft-wrap with a bare newline — they add a # leading-space "hanging indent" on the continuation line too, so removing # only '\n' still leaves an extra space that breaks an exact literal match. # Matching with all whitespace collapsed out sidesteps wrap formatting # entirely, whatever shape it takes. local marker_norm marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]') _pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; } deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} )) while _pane_dialog_open "$sess"; do if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then _sks_herdr send-keys -t "$sess" Escape sleep 1 fi if [ "$(date +%s)" -ge "$deadline" ]; then echo "send_keys_safe: dialog blocking input ($sess)" >&2 return 2 fi sleep 2 done local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)" _sks_herdr set-buffer -b "$sks_buf" "$text" _sks_herdr paste-buffer -b "$sks_buf" -t "$sess" _sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true if [[ "$sess" =~ "agy" ]]; then _sks_herdr send-keys -t "$sess" C-m return 0 fi local was_popup=0 if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]]; then # Skip strict paste check due to scrollout false-positives, proceed to C-m loop true else sleep 0.5 local pane_content pane_content=$(_pane_capture "$sess") if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then was_popup=1 # Strip ALL whitespace before matching — the rendered pane soft-wraps long # lines at the terminal width (and some TUIs add indentation on the # continuation line too), which can split/reformat the marker across two # visual lines and make a literal grep miss it even though the paste landed. elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then echo "send_keys_safe: paste not visible ($sess)" >&2 return 3 fi fi for try in 1 2 3; do pre_submit=$(_pane_capture "$sess") _sks_herdr send-keys -t "$sess" C-m sleep "$try" local cur_content cur_content=$(_pane_capture "$sess") # Hardened Submission Checks if printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing"; then return 0 fi if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then return 0 elif [ "$was_popup" = "1" ] && \ ! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \ [ "$cur_content" != "$pre_submit" ]; then return 0 fi done echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2 return 4 } # handle_startup_dialogs [timeout_sec=20] # Post-start/resume dialog policy for claude: accept the trust / bypass # dialogs ONLY when their signature is positively on screen; return as soon # as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence. handle_startup_dialogs() { local sess="$1" timeout="${2:-20}" waited=0 pane while [ "$waited" -lt "$timeout" ]; do pane=$(_pane_tail "$sess" 20) if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then _sks_herdr send-keys -t "$sess" Enter elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then _sks_herdr send-keys -t "$sess" Down sleep 0.3 _sks_herdr send-keys -t "$sess" Enter elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then _sks_herdr send-keys -t "$sess" Enter elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then return 0 fi sleep 2 waited=$((waited + 2)) done return 0 } # Auto-initialize the herdr translation shim wrapper on library source _init_herdr_isolation