- The ASCII branch of _sanitize_herdr_agent_name translates line breaks
before sed (line by line) and $(...) (drops a trailing one) can lose
them: "newline-creator-claude\n" was started as newline-creator-claude
while its attach_command named newline-creator-claude-.
- `load_state_json strict` exits 1 (stderr) when the YAML or DB cannot be
read instead of printing {}; without the argument nothing changes.
Refs #71
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3234 lines
125 KiB
Bash
3234 lines
125 KiB
Bash
#!/usr/bin/env bash
|
|
# lib.sh — shared library for the multi-agent-mux-* skills.
|
|
#
|
|
# Single source of truth for the four things that were inconsistently
|
|
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
|
|
# - derive_session_name : the herdr session slug (P0-A)
|
|
# - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B)
|
|
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
|
|
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
|
|
#
|
|
# Source it from each script with a path computed from the script location:
|
|
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
|
#
|
|
# HARD RULE: the agent-sessions.yaml file is only ever written through
|
|
# atomic_dump_yaml. Never `open(yaml_path, 'w')` anywhere else.
|
|
|
|
if [ -z "${BASH_VERSION:-}" ]; then
|
|
echo "ERROR: lib.sh must be executed/sourced from bash (foreign shell detected)" >&2
|
|
# Reachable when this file is executed directly rather than sourced;
|
|
# `return` only fails (falling through to `exit`) in that path.
|
|
# shellcheck disable=SC2317
|
|
return 1 2>/dev/null || exit 1
|
|
fi
|
|
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
|
|
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
|
export WORKSPACE_ROOT
|
|
|
|
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
|
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
|
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
|
MAM_VERSION="4.10.1"
|
|
export MAM_VERSION
|
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
|
|
|
# The one bash form of lib_py.agents.sanitize.sanitize_herdr_agent_name
|
|
# (Issue #71). The herdr shim gets this same definition: _init_herdr_isolation
|
|
# writes it in with `declare -f`. A long name needs its SHA-1 suffix; with no
|
|
# shasum, sha1sum, openssl or python3 to make it, this fails (stderr, nonzero)
|
|
# rather than return a name herdr never registered. A name that is not ASCII
|
|
# is lowercased and replaced by python3 (tr/sed differ from str.lower() there),
|
|
# and fails the same way without it. Line breaks become '-' before sed (which
|
|
# works line by line) and before $(...) could drop a trailing one.
|
|
_sanitize_herdr_agent_name() {
|
|
local n="$1"
|
|
if [ -z "$n" ]; then
|
|
printf 'agent\n'
|
|
return 0
|
|
fi
|
|
local s=""
|
|
if [ -z "$(printf '%s' "$n" | LC_ALL=C tr -d '\000-\177')" ]; then
|
|
s=$(printf '%s' "$n" | tr '\n' '-' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
s=$(python3 -c 'import re,sys; sys.stdout.write(re.sub(r"[^a-z0-9_-]", "-", sys.argv[1].lower()))' "$n") || s=""
|
|
fi
|
|
if [ -z "$s" ]; then
|
|
echo "ERROR: cannot make the herdr name of '$n': it is not ASCII and there is no python3 to convert it" >&2
|
|
return 1
|
|
fi
|
|
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)
|
|
fi
|
|
if [ "${#h}" -ne 8 ] && command -v sha1sum >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
fi
|
|
if [ "${#h}" -ne 8 ] && 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 [ "${#h}" -ne 8 ] && command -v python3 >/dev/null 2>&1; then
|
|
h=$(python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode("utf-8")).hexdigest()[:8])' "$s" 2>/dev/null || true)
|
|
fi
|
|
if [ "${#h}" -ne 8 ]; then
|
|
echo "ERROR: cannot make the herdr name of '$n': no shasum, sha1sum, openssl or python3 to hash it" >&2
|
|
return 1
|
|
fi
|
|
s="${s:0:23}-${h}"
|
|
fi
|
|
printf '%s\n' "$s"
|
|
}
|
|
|
|
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
|
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
|
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
|
export PATH="$PATH:$dir"
|
|
fi
|
|
done
|
|
|
|
# Central TUI dialog and readiness validation tokens (OP-6)
|
|
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
|
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
|
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
|
|
|
# Workspace-relative defaults with environment overrides (Phase Z)
|
|
HOME_DIR="${HOME_DIR:-$HOME}"
|
|
# claude keeps projects/ under its config root, which CLAUDE_CONFIG_DIR moves (Issue #31).
|
|
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects}"
|
|
if [ -z "${LOCAL_BIN:-}" ]; then
|
|
if [ -x "$HOME_DIR/bin/herdr" ]; then
|
|
LOCAL_BIN="$HOME_DIR/bin"
|
|
elif [ -x "$HOME_DIR/.local/bin/herdr" ]; then
|
|
LOCAL_BIN="$HOME_DIR/.local/bin"
|
|
else
|
|
LOCAL_BIN="$HOME/.local/bin"
|
|
fi
|
|
fi
|
|
export HOME_DIR CLAUDE_PROJECT_DIR LOCAL_BIN
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Herdr Server Isolation support
|
|
# ---------------------------------------------------------------------------
|
|
# Paths to exclude when resolving the real herdr binary (shim/wrapper dirs).
|
|
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
|
|
_HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
|
|
|
|
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}"
|
|
|
|
_canonical_file() {
|
|
local p="$1" t d b i=0
|
|
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
|
|
t="$(readlink "$p" 2>/dev/null)" || break
|
|
case "$t" in
|
|
/*) p="$t" ;;
|
|
*) p="$(dirname "$p")/$t" ;;
|
|
esac
|
|
i=$((i + 1))
|
|
done
|
|
d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1
|
|
b="$(basename "$p")"
|
|
printf '%s/%s\n' "$d" "$b"
|
|
}
|
|
|
|
_is_shim_path() {
|
|
case "/$1/" in
|
|
*"/.mam/shim/"*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*)
|
|
return 0 ;;
|
|
esac
|
|
# A wrapper directory named *-shim, the directory itself only: an ancestor
|
|
# such as a pytest basetemp "pytest-x-shim" above a mock's bin/ made the
|
|
# resolver skip the mock and run the real herdr.
|
|
case "${1%/}" in
|
|
*-shim) return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
_resolve_real_herdr_path() {
|
|
local dir cand save_ifs="$IFS" real_path=""
|
|
IFS=:
|
|
for dir in $PATH; do
|
|
[ -n "$dir" ] || continue
|
|
_is_shim_path "$dir" && continue
|
|
[ -x "$dir/herdr" ] || continue
|
|
cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr"
|
|
[ -n "$cand" ] || cand="$dir/herdr"
|
|
_is_shim_path "$(dirname "$cand")" && continue
|
|
real_path="$dir/herdr"
|
|
break
|
|
done
|
|
IFS="$save_ifs"
|
|
[ -n "$real_path" ] || return 1
|
|
printf '%s\n' "$real_path"
|
|
}
|
|
|
|
has_real_herdr() {
|
|
_resolve_real_herdr_path >/dev/null 2>&1
|
|
}
|
|
|
|
_init_herdr_isolation() {
|
|
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$wrapper_dir"
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
|
|
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
|
|
{
|
|
printf '%s\n' '#!/usr/bin/env bash'
|
|
declare -f _sanitize_herdr_agent_name
|
|
cat <<'EOF'
|
|
# Herdr-to-Herdr translation shim wrapper
|
|
set -euo pipefail
|
|
|
|
# This shim can run as a standalone subprocess (e.g. spawned directly by
|
|
# herdr) without inheriting the parent shell's PYTHONPATH export from
|
|
# lib.sh, which breaks the `python3 -m lib_py...` / `from lib_py...` calls
|
|
# below. Re-derive it from the shim's own on-disk location instead of
|
|
# assuming inheritance.
|
|
_SHIM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_SKILL_DIR="$(cd "$_SHIM_DIR/../../.agents/skills" 2>/dev/null && pwd || true)"
|
|
[ -n "$_SKILL_DIR" ] && export PYTHONPATH="$_SKILL_DIR:${PYTHONPATH:-}"
|
|
|
|
# Dynamic real herdr path resolution to prevent infinite recursion
|
|
_resolve_real_herdr() {
|
|
local dir save_ifs="$IFS" real_path=""
|
|
IFS=:
|
|
for dir in $PATH; do
|
|
if [[ "$dir" != *".mam/shim"* ]] && [[ "${dir%/}" != *-shim ]] && [ -x "$dir/herdr" ]; then
|
|
real_path="$dir/herdr"
|
|
break
|
|
fi
|
|
done
|
|
IFS="$save_ifs"
|
|
if [ -z "$real_path" ]; then
|
|
real_path="herdr"
|
|
fi
|
|
echo "$real_path"
|
|
}
|
|
REAL_HERDR=$(_resolve_real_herdr)
|
|
|
|
# Support parsing -L <session> before the subcommand
|
|
while [ "${1:-}" = "-L" ]; do
|
|
if [ $# -lt 2 ]; then
|
|
echo "herdr shim: -L requires an argument" >&2
|
|
exit 1
|
|
fi
|
|
export HERDR_SESSION_NAME="$2"
|
|
shift 2
|
|
done
|
|
|
|
_MAM_SESSION="${HERDR_SESSION_NAME:-default}"
|
|
if [ "$_MAM_SESSION" = "default" ]; then
|
|
_MAM_SESSION=""
|
|
elif [ "${MAM_NO_AUTOSTART:-0}" = "1" ]; then
|
|
# ISSUE-9: query-only callers (status.sh, reconcile.sh) must never start a
|
|
# daemon as a side effect of looking. A session whose server is down just
|
|
# answers server_not_running, which every caller already reads as "no live
|
|
# agents" - the same answer a freshly bootstrapped, empty server would give.
|
|
:
|
|
else
|
|
_mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c "
|
|
import sys, json, os
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
name = os.environ.get('MAM_SESS', '')
|
|
print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0')
|
|
except Exception:
|
|
print('0')
|
|
" 2>/dev/null || echo "0")
|
|
if [ "$_mam_session_running" != "1" ]; then
|
|
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
|
# blocks a normal interactive `herdr --session <name>` launch from inside
|
|
# an existing herdr pane (which is how MAM's own agents usually run).
|
|
# ISSUE-3: plain `nohup ... & disown` only ignores SIGHUP and drops bash's
|
|
# own job-table tracking - it does NOT create a new process group/session,
|
|
# so the daemon stays in the caller's PGID and dies when a process-group
|
|
# signal (SIGTERM/SIGINT from a test runner, timeout wrapper, or parent
|
|
# supervisor teardown) is broadcast to that group. setsid(1) is not
|
|
# installed by default on macOS, so spawn via Python's
|
|
# start_new_session=True (os.setsid in the child) instead - portable
|
|
# across macOS and Linux, and lib_py already depends on Python.
|
|
#
|
|
# ISSUE-9: the caller is usually an agent CLI itself (a Claude Code
|
|
# orchestrator pane), and herdr hands the daemon's environment to every
|
|
# pane it later spawns. An inherited CLAUDE_CODE_CHILD_SESSION makes each
|
|
# sub-agent claude disable transcript saving (no <uuid>.jsonl, so resume
|
|
# is impossible), and the other markers advertise the orchestrator's own
|
|
# identity to its sub-agents. Drop only per-session runtime markers - user
|
|
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
|
|
# inherits. The family ids mirror what orc_onboard.sh auto-detects.
|
|
#
|
|
# ISSUE-45: the server restores its saved layout on start and would
|
|
# relaunch every saved agent with herdr's own resume command (no permission
|
|
# flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's
|
|
# herdr config with [session] resume_agents_on_restore = false, rebuilt on
|
|
# every start and checked by this herdr's `config check`
|
|
# (lib_py/herdr_config.py). Without that file the server still starts -
|
|
# with restore on, as before.
|
|
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." --herdr "$REAL_HERDR" || true)
|
|
export MAM_SERVER_CFG
|
|
if [ -z "$MAM_SERVER_CFG" ]; then
|
|
echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2
|
|
fi
|
|
_mam_server_pid=$(python3 -c '
|
|
import os, subprocess, sys
|
|
deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
|
|
"CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PID",
|
|
"CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET",
|
|
"CLAUDE_CODE_BRIDGE_SESSION_ID",
|
|
"ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID",
|
|
"OPENCODE_SESSION_ID", "MAM_SERVER_CFG")
|
|
env = {k: v for k, v in os.environ.items() if k not in deny}
|
|
if os.environ.get("MAM_SERVER_CFG"):
|
|
env["HERDR_CONFIG_PATH"] = os.environ["MAM_SERVER_CFG"]
|
|
p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL)
|
|
print(p.pid)
|
|
' "$REAL_HERDR" --session "$_MAM_SESSION" server)
|
|
for _mam_wait_i in $(seq 1 40); do
|
|
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
|
kill -0 "$_mam_server_pid" 2>/dev/null || break
|
|
sleep 0.25
|
|
done
|
|
fi
|
|
fi
|
|
|
|
_real_herdr() {
|
|
local session="${HERDR_SESSION_NAME:-}"
|
|
if [ "$session" = "default" ]; then
|
|
session=""
|
|
fi
|
|
if [ -n "$session" ]; then
|
|
if [ "${1:-}" = "--session" ] || [ "${1:-}" = "-s" ]; then
|
|
"$REAL_HERDR" "$@"
|
|
else
|
|
"$REAL_HERDR" --session "$session" "$@"
|
|
fi
|
|
else
|
|
"$REAL_HERDR" "$@"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workspace scoping (ISSUE-3).
|
|
#
|
|
# Hard filter when HERDR_WORKSPACE_ID is set, else the workspace id this
|
|
# shim last persisted under $WORKSPACE_ROOT/.mam/herdr_workspace_id.
|
|
# Do not infer from cwd — that silently blocks legitimate cross-workspace
|
|
# lookups. No env and no file keeps the previous server-global lookup.
|
|
# ---------------------------------------------------------------------------
|
|
_herdr_ws_id_file() {
|
|
if [ -n "${WORKSPACE_ROOT:-}" ]; then
|
|
printf '%s\n' "$WORKSPACE_ROOT/.mam/herdr_workspace_id"
|
|
fi
|
|
}
|
|
|
|
_herdr_persist_ws_id() {
|
|
local id="$1" f
|
|
[ -n "$id" ] || return 0
|
|
f=$(_herdr_ws_id_file)
|
|
[ -n "$f" ] || return 0
|
|
mkdir -p "$(dirname "$f")" 2>/dev/null || true
|
|
printf '%s\n' "$id" > "$f" 2>/dev/null || true
|
|
}
|
|
|
|
_herdr_ws_scope() {
|
|
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
|
|
printf '%s\n' "$HERDR_WORKSPACE_ID"
|
|
return 0
|
|
fi
|
|
local f id=""
|
|
f=$(_herdr_ws_id_file)
|
|
if [ -n "$f" ] && [ -f "$f" ]; then
|
|
id=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
|
fi
|
|
printf '%s\n' "$id"
|
|
}
|
|
|
|
# _herdr_agent_get_scoped <name> [workspace_id]
|
|
#
|
|
# agent get with the same workspace predicate as _resolve_herdr_pane_id.
|
|
# Prints pane_id on stdout and returns 0 when the agent exists and is in
|
|
# scope. Returns 1 (prints nothing) if missing or in another workspace.
|
|
# Callers MUST wrap with `|| true`.
|
|
_herdr_agent_get_scoped() {
|
|
local name="$1"
|
|
local target_ws pid=""
|
|
# Default scope is the explicit env var only. The persisted workspace
|
|
# file is NOT used here: one MAM workspace can own several herdr
|
|
# workspaces (layout overflow / parallel create), so a single file
|
|
# must not reject a uniquely named agent. File scope applies to pane
|
|
# list / label lookup via _herdr_ws_scope.
|
|
if [ "$#" -ge 2 ]; then
|
|
target_ws="$2"
|
|
else
|
|
target_ws="${HERDR_WORKSPACE_ID:-}"
|
|
fi
|
|
[ -n "$name" ] || return 1
|
|
pid=$(_real_herdr agent get "$name" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
|
|
import sys, json, os
|
|
tws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
a = json.load(sys.stdin).get('result', {}).get('agent', {})
|
|
if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
|
|
sys.exit(1)
|
|
pid = a.get('pane_id') or ''
|
|
if pid:
|
|
print(pid)
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "")
|
|
if [ -n "$pid" ]; then
|
|
printf '%s\n' "$pid"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# _resolve_herdr_pane_id <target> [workspace_id]
|
|
#
|
|
# Resolve a session name / label to a real pane_id ("wN:pM").
|
|
# Strict order (no substring matching at any step):
|
|
# 1. herdr agent get <sanitized_name> (workspace-scoped)
|
|
# 2. herdr agent get <raw_name> (workspace-scoped)
|
|
# 3. herdr pane list [--workspace WS] matching
|
|
# 3-a. label exact
|
|
# 3-b. name exact
|
|
# 3-c. agent exact
|
|
# On success print pane_id on stdout and return 0; on failure print nothing
|
|
# and return 1. Callers MUST wrap with `|| true` (shim runs under set -e).
|
|
_resolve_herdr_pane_id() {
|
|
local target="$1"
|
|
# Fast path: caller already passed a real pane_id (e.g. "w1E:p1") — skip
|
|
# agent-get/pane-list resolution entirely.
|
|
if [[ "$target" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
|
|
printf '%s\n' "$target"
|
|
return 0
|
|
fi
|
|
local target_ws="${2:-$(_herdr_ws_scope)}"
|
|
# A caller may pass a workspace *label* (e.g. $MAM_WS_LABEL) rather than
|
|
# the raw workspace_id — panes only carry workspace_id, so translate a
|
|
# label to its id via `herdr workspace list` before filtering. Falls
|
|
# back to the original value on any failure/no-match (permissive; keeps
|
|
# prior behavior when $target_ws is already a raw id).
|
|
if [ -n "$target_ws" ]; then
|
|
local resolved_ws
|
|
resolved_ws=$(_real_herdr workspace list 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
|
|
import sys, json, os
|
|
tws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
wss = json.load(sys.stdin).get('result', {}).get('workspaces', [])
|
|
ids = {w.get('workspace_id') for w in wss}
|
|
if tws in ids:
|
|
print(tws)
|
|
sys.exit(0)
|
|
hit = next((w.get('workspace_id') for w in wss if w.get('label') == tws), '')
|
|
if hit:
|
|
print(hit)
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "$target_ws")
|
|
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
|
fi
|
|
local sat pid=""
|
|
sat=$(_sanitize_herdr_agent_name "$target") || return 1
|
|
|
|
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") || return 1
|
|
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" "$@"
|
|
elif [ "$sub" = "attach" ] && [ $# -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 attach "$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
|
|
ssess=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
|
if [ -n "$(_herdr_agent_get_scoped "$ssess" 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_SAN="$ssess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
|
import sys, json, os
|
|
tn = os.environ.get("TARGET_NAME", "")
|
|
stn = 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:
|
|
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
|
|
agent_name=$(_sanitize_herdr_agent_name "$name") || exit 1
|
|
if [ -z "$run_cmd" ]; then
|
|
run_cmd="${SHELL:-/bin/bash}"
|
|
fi
|
|
|
|
parsed=$(python3 -c "
|
|
import sys, shlex
|
|
cmd = sys.argv[1]
|
|
tokens = shlex.split(cmd)
|
|
env_flags = []
|
|
binary_tokens = []
|
|
for tok in tokens:
|
|
if '=' in tok and not binary_tokens:
|
|
env_flags.append('--env ' + tok)
|
|
else:
|
|
binary_tokens.append(shlex.quote(tok))
|
|
print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
|
|
" "$run_cmd" 2>/dev/null || echo "")
|
|
|
|
env_flags=""
|
|
final_cmd="$run_cmd"
|
|
if [ -n "$parsed" ]; then
|
|
env_flags=$(echo "$parsed" | head -n 1 | tr '\t' ' ')
|
|
final_cmd=$(echo "$parsed" | tail -n +2)
|
|
fi
|
|
|
|
kind=""
|
|
case "$name" in
|
|
*-creator-claude|*-planner-claude|*-reviewer-claude) kind="claude" ;;
|
|
*-creator-agy|*-planner-agy|*-reviewer-agy) kind="agy" ;;
|
|
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) kind="hermes" ;;
|
|
*-creator-grok|*-planner-grok|*-reviewer-grok) kind="grok" ;;
|
|
*-creator-opencode|*-planner-opencode|*-reviewer-opencode) kind="opencode" ;;
|
|
*-creator-codex|*-planner-codex|*-reviewer-codex) kind="codex" ;;
|
|
*)
|
|
if echo "$name" | grep -qi "agy"; then kind="agy"
|
|
elif echo "$name" | grep -qi "claude"; then kind="claude"
|
|
elif echo "$name" | grep -qi "hermes"; then kind="hermes"
|
|
elif echo "$name" | grep -qi "grok"; then kind="grok"
|
|
elif echo "$name" | grep -qi "opencode"; then kind="opencode"
|
|
elif echo "$name" | grep -qi "codex"; then kind="codex"
|
|
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
|
|
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
|
|
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
|
|
elif echo "${final_cmd:-}" | grep -qi "grok"; then kind="grok"
|
|
elif echo "${final_cmd:-}" | grep -qi "opencode"; then kind="opencode"
|
|
elif echo "${final_cmd:-}" | grep -qi "codex"; then kind="codex"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# Strip duplicate agent binary name/path from final_cmd if present, preventing positional argument misinterpretation
|
|
final_cmd=$(python3 -c "
|
|
import sys, shlex
|
|
cmd = sys.argv[1]
|
|
kind = sys.argv[2]
|
|
try:
|
|
tokens = shlex.split(cmd)
|
|
if tokens:
|
|
first = tokens[0]
|
|
if first in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')) or (kind and (first == kind or first.endswith('/' + kind))):
|
|
tokens = tokens[1:]
|
|
print(' '.join(shlex.quote(t) for t in tokens))
|
|
except Exception:
|
|
print(cmd)
|
|
" "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd")
|
|
|
|
# W1: Resolve candidate workspaces by querying pane list for matching CWD
|
|
# (WorkspaceInfo has no cwd key). ISSUE-7: every workspace on this cwd is
|
|
# a candidate, in pane order; the first one with an idle pane or with
|
|
# room in its 2xK grid takes the agent, and a fresh workspace is created
|
|
# only when all of them are full. Taking just the first match found the
|
|
# full workspace every time and re-judged it overflow, so each agent past
|
|
# the fourth opened yet another workspace (4 + 1 + 1 instead of 4 + 2).
|
|
candidate_wss=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_CWD', '')
|
|
try:
|
|
target_abs = os.path.realpath(target_ws) if target_ws else ''
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
seen = []
|
|
for p in panes:
|
|
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
|
|
wid = p.get('workspace_id')
|
|
if target_abs and p_cwd == target_abs and wid and wid not in seen:
|
|
seen.append(wid)
|
|
print('\n'.join(seen))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
ws_id=""
|
|
target_pane=""
|
|
existing_ws=""
|
|
for cand_ws in $candidate_wss; do
|
|
[ -n "$cand_ws" ] || continue
|
|
# BUG-4: prefer an already-idle (agent-less) pane in the existing
|
|
# workspace over splitting a new one — avoids unbounded pane
|
|
# proliferation and 2x2 grid-layout churn when a prior agent's pane
|
|
# was left behind idle.
|
|
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
for p in panes:
|
|
if p.get('workspace_id') == target_ws and not p.get('agent') and p.get('pane_id'):
|
|
print(p.get('pane_id'))
|
|
break
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
# An idle pane is reused only when no environment has to be set: the
|
|
# pane's shell is already running, and `agent start ... -- <args>` hands
|
|
# every token after -- to the agent executable (--kind), so an
|
|
# `env K=V` prefix would reach the agent as arguments, not as its
|
|
# environment (Issue #21 R1). With env flags, fall through to
|
|
# pane split / workspace create, which take --env.
|
|
if [ -n "$idle_pane" ] && [ -z "$env_flags" ]; then
|
|
existing_ws="$cand_ws"
|
|
target_pane="$idle_pane"
|
|
break
|
|
fi
|
|
|
|
# W2a: Determine split direction policy via pane layout
|
|
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
for p in panes:
|
|
if p.get('workspace_id') == target_ws and p.get('pane_id'):
|
|
print(p.get('pane_id'))
|
|
break
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
split_dir=""
|
|
if [ -n "$sample_pane" ]; then
|
|
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
|
|
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
|
split_dir="${split_dir:-right}"
|
|
sample_pane="${split_target:-$sample_pane}"
|
|
fi
|
|
|
|
if [ "$split_dir" = "overflow" ]; then
|
|
# W2b: this workspace's grid is full — try the next one on the same cwd
|
|
# (ISSUE-7); a fresh workspace is created below only if none has room.
|
|
continue
|
|
fi
|
|
existing_ws="$cand_ws"
|
|
[ "$split_dir" = "down" ] || split_dir="right"
|
|
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
|
|
target_pane=$(echo "$split_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
break
|
|
done
|
|
|
|
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
|
|
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" ${MAM_WS_LABEL:+--label "$MAM_WS_LABEL"} $env_flags --no-focus 2>/dev/null || echo "")
|
|
target_pane=$(echo "$ws_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
w_obj = res.get('workspace', {})
|
|
print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
ws_id=$(echo "$ws_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
print(res.get('workspace', {}).get('workspace_id') or '')
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
else
|
|
if [ -n "${MAM_WS_LABEL:-}" ]; then
|
|
_real_herdr workspace rename "$existing_ws" "$MAM_WS_LABEL" >/dev/null 2>&1 || true
|
|
fi
|
|
ws_id="$existing_ws"
|
|
fi
|
|
if [ -n "${ws_id:-}" ]; then
|
|
export HERDR_WORKSPACE_ID="$ws_id"
|
|
_herdr_persist_ws_id "$ws_id"
|
|
fi
|
|
|
|
if [ -z "$target_pane" ]; then
|
|
echo "Error: target_pane allocation failed for $name" >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$kind" ]; then
|
|
echo "Error: unable to determine agent kind for session $name" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors
|
|
res=""
|
|
success=0
|
|
backoffs=(0.5 1 2)
|
|
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") || exit 1
|
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
|
fi
|
|
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 \
|
|
|| _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
|
|
;;
|
|
list-panes)
|
|
sess="" format=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
-F)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -F requires a value" >&2
|
|
exit 1
|
|
fi
|
|
format="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
# NOTE: `herdr agent get <target>` returns {"result": {"agent": {...}}} —
|
|
# there is no top-level "pane" key, and no "pid" field at all (only
|
|
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
|
# second call to `pane process-info --pane <pane_id>`.
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || exit 1
|
|
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") || exit 1
|
|
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") || exit 1
|
|
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
|
|
} > "$tmp_file"
|
|
chmod +x "$tmp_file" 2>/dev/null || true
|
|
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
}
|
|
|
|
# _herdr_call_is_cross_server
|
|
#
|
|
# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID /
|
|
# HERDR_TAB_ID, and those ids only mean something on the server that issued
|
|
# them. When a MAM script runs inside one server's pane (the orchestrator's)
|
|
# but targets another (the isolated MAM server named by HERDR_SESSION_NAME),
|
|
# the inherited ids reach the scope filters (_herdr_ws_scope,
|
|
# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and
|
|
# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a
|
|
# live session as absent: has-session, kill-session, agent prompt and the
|
|
# delegate-job preflight all answer "no such session".
|
|
#
|
|
# _herdr_caller_server — the name of the server whose pane this process runs
|
|
# in; empty when it is not in a pane at all.
|
|
#
|
|
# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a
|
|
# pane of the *default* server (the orchestrator's usual home), which is
|
|
# exactly the pane the #23 leak comes from - the live trial of this branch
|
|
# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is
|
|
# exported to every pane, and its layout names the server:
|
|
#
|
|
# $HOME/.config/herdr/herdr.sock -> default
|
|
# $HOME/.config/herdr/sessions/<name>/herdr.sock -> <name>
|
|
#
|
|
# (the same convention lib.sh's shim already relies on to find a named
|
|
# server's socket). Matched by shape, not by $HOME, so a redirected HOME does
|
|
# not break it. An unrecognised layout yields "" - treated as "not in a pane",
|
|
# which keeps today's behaviour rather than guessing.
|
|
_herdr_caller_server() {
|
|
if [ -n "${HERDR_SESSION:-}" ]; then
|
|
printf '%s\n' "$HERDR_SESSION"
|
|
return 0
|
|
fi
|
|
local sock="${HERDR_SOCKET_PATH:-}"
|
|
case "$sock" in
|
|
*/.config/herdr/herdr.sock)
|
|
printf 'default\n'
|
|
;;
|
|
*/.config/herdr/sessions/*/herdr.sock)
|
|
local d="${sock%/herdr.sock}"
|
|
printf '%s\n' "${d##*/}"
|
|
;;
|
|
*)
|
|
printf '\n'
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# True only for a call aimed at a *named* server other than the caller's own:
|
|
#
|
|
# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel
|
|
# for "pass no session flag", so herdr answers on the ambient server, which
|
|
# for a caller inside a pane is that same caller's server - not a crossing;
|
|
# * no caller server (_herdr_caller_server empty) means the caller is not in
|
|
# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope,
|
|
# not a leak (H-18/H-21/H-23);
|
|
# * caller == target is the same server.
|
|
#
|
|
# A caller in the default server's pane targeting a named server IS a crossing
|
|
# (the reported #23 scenario): `default` is a real server name on the caller's
|
|
# side, only as a *target* is it the "ambient" sentinel.
|
|
_herdr_call_is_cross_server() {
|
|
local caller target="${HERDR_SESSION_NAME:-}"
|
|
case "$target" in
|
|
""|default) return 1 ;;
|
|
esac
|
|
caller="$(_herdr_caller_server)"
|
|
if [ -n "$caller" ] && [ "$caller" != "$target" ]; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# _herdr_scope_is_pane_inherited <herdr argv...>
|
|
#
|
|
# ISSUE-34: true for a call that names an agent (agent / has-session /
|
|
# kill-session) from inside a pane whose own workspace is the exported
|
|
# HERDR_WORKSPACE_ID. A pane exports it together with HERDR_PANE_ID
|
|
# "<workspace>:<pane>"; when the two agree the id says where the caller lives,
|
|
# not where to look - a worktree creator in its own workspace must reach a
|
|
# reviewer in mam-agent on the same server. Live agent names are unique per
|
|
# server (herdr --skill: "Names must ... be unique among live agents", "IDs and
|
|
# live agent names are scoped to one server"), so a name lookup without that
|
|
# filter cannot pick a same-named agent of another workspace: the ISSUE-3 /
|
|
# F-2b hazard exists only across servers (_herdr_call_is_cross_server). An id
|
|
# that disagrees with the pane, or one set outside any pane, is still a
|
|
# deliberate scope (H-18/H-21/H-23); new-session and pane calls keep it, so a
|
|
# pane caller still creates panes in its own workspace.
|
|
_herdr_scope_is_pane_inherited() {
|
|
while [ "${1:-}" = "-L" ]; do shift 2 || return 1; done
|
|
case "${1:-}" in
|
|
agent|has-session|kill-session) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
local pane="${HERDR_PANE_ID:-}"
|
|
[ -n "${HERDR_WORKSPACE_ID:-}" ] && [ -n "$pane" ] && [ "${pane%%:*}" = "$HERDR_WORKSPACE_ID" ]
|
|
}
|
|
|
|
# Issue #50 follow-up: one pane claim at a time per herdr server.
|
|
#
|
|
# The shim's new-session chooses a pane - an idle one in a workspace on the
|
|
# same cwd (BUG-4), a split of one, or the root pane of a fresh workspace - and
|
|
# then claims it with `agent start`. Two shims on one server (several creates
|
|
# at once into one folder) could choose the same idle pane in between, and the
|
|
# later `agent start` failed with agent_pane_busy. mam_herdr runs the whole
|
|
# new-session call holding one lock per server: a flock on fd 9, opened for
|
|
# the block below and taken by lib_py.pane_lock (which also bounds the wait,
|
|
# MAM_PANE_CLAIM_WAIT_S). It ends with the block, or when every process that
|
|
# holds fd 9 - this shell and the shim it runs - has ended, however that ends,
|
|
# so a dead holder leaves nothing behind. Nothing long-lived may inherit fd 9
|
|
# (the herdr server is spawned with close_fds; review 66680d9d); the file is the target
|
|
# server's, so other servers' creates are not held. The lock lives here, not
|
|
# in the shim heredoc, which stays byte-identical (test_issue23_24 S11).
|
|
#
|
|
# _mam_pane_claim_target <herdr argv...>: prints the server a new-session call
|
|
# names ("" for the ambient target, which lib_py.pane_lock resolves to the
|
|
# caller pane's server - the one herdr answers on - or the default server);
|
|
# rc 1 for any other call.
|
|
_mam_pane_claim_target() {
|
|
local sess="${HERDR_SESSION_NAME:-}"
|
|
while [ "${1:-}" = "-L" ]; do sess="${2:-}"; shift 2 || return 1; done
|
|
[ "${1:-}" = "new-session" ] || return 1
|
|
[ "$sess" = "default" ] && sess=""
|
|
printf '%s\n' "$sess"
|
|
}
|
|
|
|
# _mam_with_pane_claim <server> <cmd...>: run <cmd> holding <server>'s pane lock.
|
|
_mam_with_pane_claim() {
|
|
local sess="$1" f=""
|
|
shift
|
|
f=$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock path --session "$sess") || f=""
|
|
if [ -z "$f" ]; then
|
|
echo "ERROR: no pane lock path for herdr session ${sess:-default} (lib_py.pane_lock); not placing this session" >&2
|
|
return 1
|
|
fi
|
|
mkdir -p "$(dirname "$f")"
|
|
{
|
|
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 9)" in
|
|
locked) ;;
|
|
timeout)
|
|
echo "ERROR: another pane claim on herdr session ${sess:-default} did not end within MAM_PANE_CLAIM_WAIT_S; not placing this session" >&2
|
|
return 1 ;;
|
|
*)
|
|
echo "ERROR: the pane lock of herdr session ${sess:-default} could not be decided (lib_py.pane_lock); not placing this session" >&2
|
|
return 1 ;;
|
|
esac
|
|
"$@"
|
|
} 9>>"$f"
|
|
}
|
|
|
|
mam_herdr() {
|
|
_init_herdr_isolation
|
|
# Built as an argv array and decided inside `if`: this runs under
|
|
# `set -euo pipefail` in most callers, where a bare failing test would abort
|
|
# the whole script instead of this one call.
|
|
local -a _scope_strip=()
|
|
if _herdr_call_is_cross_server; then
|
|
_scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID)
|
|
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
|
echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2
|
|
fi
|
|
elif _herdr_scope_is_pane_inherited "$@"; then
|
|
_scope_strip=(env -u HERDR_WORKSPACE_ID)
|
|
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
|
echo "mam_herdr: '$1' looks up by name; the caller pane's own workspace ${HERDR_WORKSPACE_ID} is not a scope" >&2
|
|
fi
|
|
fi
|
|
local _claim_sess=""
|
|
if _claim_sess=$(_mam_pane_claim_target "$@"); then
|
|
_mam_with_pane_claim "$_claim_sess" ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
|
return
|
|
fi
|
|
${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
|
}
|
|
|
|
_herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_herdr_server <session_name>
|
|
#
|
|
# Query agent-sessions.yaml to find the herdr_session associated with a session.
|
|
# Fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME or workspace slug or 'default' if not registered.
|
|
# Prints the resolved server name on stdout.
|
|
# ---------------------------------------------------------------------------
|
|
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
|
|
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
|
|
# `load_state_json strict` (Issue #71) fails (stderr, exit 1) when the YAML or
|
|
# DB cannot be read, instead of printing {} - for a caller that acts on a
|
|
# session being absent.
|
|
load_state_json() {
|
|
env_python "$AGENT_SESSIONS_YAML" "MAM_STATE_READ=${1:-}" <<'PYEOF'
|
|
import os, sys, sqlite3, json, yaml
|
|
yaml_path = os.environ['YAML_PATH']
|
|
strict = os.environ.get('MAM_STATE_READ') == 'strict'
|
|
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 as e:
|
|
if strict and 'no such table' not in str(e):
|
|
raise
|
|
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 as e:
|
|
if strict and 'no such table' not in str(e):
|
|
raise
|
|
conn.close()
|
|
elif os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
except Exception as e:
|
|
if strict:
|
|
print('ERROR: cannot read the registry %s: %s' % (yaml_path, e), file=sys.stderr)
|
|
sys.exit(1)
|
|
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
|
def clean_surrogates(obj):
|
|
if isinstance(obj, str):
|
|
return obj.encode('utf-8', errors='replace').decode('utf-8')
|
|
elif isinstance(obj, dict):
|
|
return {k: clean_surrogates(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [clean_surrogates(x) for x in obj]
|
|
return obj
|
|
d = clean_surrogates(d)
|
|
print(json.dumps(d, ensure_ascii=False))
|
|
PYEOF
|
|
}
|
|
|
|
# resolve_agent_type_from_registry <session_name>
|
|
#
|
|
# 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는
|
|
# lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다:
|
|
# ① row['agent'] 명시 필드
|
|
# ② 세션명 접미사 (*-{creator,planner,reviewer}-<agent> 및 *-<agent>)
|
|
# ③ pane.cmd (정확히 일치하거나 .../<agent> 바이너리 경로)
|
|
# 성공하면 에이전트명을 stdout 에 출력하고 0 을, 셋 다 실패하면 아무것도
|
|
# 출력하지 않고 1 을 반환한다. 오류 메시지는 호출자가 소유한다 — 각 스크립트가
|
|
# 문서화한 종료 코드를 그대로 유지하기 위해서다.
|
|
#
|
|
# NOTE: agent_of_row 의 match_cmd=True 는 "비-입양 조회" 계약이다. reconcile.sh
|
|
# 입양 루프는 이 헬퍼를 쓰면 안 된다 (3aee63cf §1.2 실측 반증).
|
|
resolve_agent_type_from_registry() {
|
|
local name="$1"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$name" python3 -c "
|
|
import os, json, sys
|
|
from lib_py.agents.registry import agent_of_row
|
|
name = os.environ['SESSION_NAME']
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
|
|
resolved = agent_of_row(row, session_name=name)
|
|
if not resolved:
|
|
sys.exit(1)
|
|
print(resolved)
|
|
"
|
|
}
|
|
|
|
# resolve_herdr_session <session_name> [workspace]
|
|
#
|
|
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
|
# not a workspace label. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
|
|
# generated wrapper) — a workspace label match provides no actual isolation
|
|
# since agent/pane commands are server-global regardless of workspace.
|
|
|
|
resolve_herdr_session() {
|
|
local session_name="$1"
|
|
local workspace="${2:-}"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
|
|
import sys, os, json
|
|
name = os.environ['SESSION_NAME']
|
|
ws = os.environ.get('TARGET_WS', '').strip()
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == name:
|
|
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
|
|
val = s.get('herdr_session') or s.get('herdr_server')
|
|
if val and val != 'default':
|
|
print(val)
|
|
sys.exit(0)
|
|
fallback = ''
|
|
if ws:
|
|
abs_ws = os.path.abspath(ws)
|
|
parent = os.path.basename(os.path.dirname(abs_ws)) or 'workspace'
|
|
work = os.path.basename(abs_ws) or 'root'
|
|
if parent in ('/', '.'): parent = 'workspace'
|
|
if work in ('/', '.'): work = 'root'
|
|
slug = f'{parent}-{work}'.lower().replace('_', '-')
|
|
import re
|
|
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lstrip('-')
|
|
fallback = f'mam-{slug}' if slug else 'mam-ws'
|
|
else:
|
|
fallback = os.environ.get('HERDR_SESSION_NAME', '') or os.environ.get('HERDR_SERVER_NAME', '')
|
|
if not fallback or fallback == 'default':
|
|
fallback = 'default'
|
|
print('WARN: resolve_herdr_session called without workspace parameter for unregistered session; falling back to default', file=sys.stderr)
|
|
print(fallback or 'default')
|
|
"
|
|
}
|
|
|
|
# resolve_herdr_workspace <session_name> [workspace]
|
|
#
|
|
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
|
|
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
|
|
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
|
|
#
|
|
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
|
|
# ① row['herdr_workspace'] — 명시 기록
|
|
# ② row['pane']['cwd'] 의 슬러그 — 등록된 세션의 실제 작업 디렉터리
|
|
# ③ 인자 workspace 의 슬러그 — 미등록 세션 전용 폴백
|
|
# ④ 빈 문자열
|
|
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
|
|
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
|
|
# pane.cwd 를 물려받기 때문 (D5).
|
|
resolve_herdr_workspace() {
|
|
local session_name="$1"
|
|
local workspace="${2:-}"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
|
|
import sys, os, json, re
|
|
name = os.environ['SESSION_NAME']
|
|
ws = os.environ.get('TARGET_WS', '').strip()
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
|
|
def slug(path):
|
|
if not path:
|
|
return ''
|
|
a = os.path.abspath(path)
|
|
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
|
|
work = os.path.basename(a) or 'root'
|
|
if parent in ('/', '.'): parent = 'workspace'
|
|
if work in ('/', '.'): work = 'root'
|
|
s = f'{parent}-{work}'.lower().replace('_', '-')
|
|
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
|
|
|
|
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)
|
|
|
|
# ① 명시 기록
|
|
if row and row.get('herdr_workspace'):
|
|
print(row['herdr_workspace']); sys.exit(0)
|
|
|
|
# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
|
|
if row:
|
|
derived = slug((row.get('pane') or {}).get('cwd', ''))
|
|
if derived:
|
|
print(derived); sys.exit(0)
|
|
|
|
# ③ 미등록(또는 cwd 부재) 세션 폴백
|
|
if ws:
|
|
derived = slug(ws)
|
|
if derived:
|
|
print(derived); sys.exit(0)
|
|
|
|
print('')
|
|
"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_workspace_slug <workspace>
|
|
# ---------------------------------------------------------------------------
|
|
derive_workspace_slug() {
|
|
local workspace="${1:-$PWD}"
|
|
local abs parent work slug
|
|
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
|
parent="$(basename "$(dirname "$abs")" 2>/dev/null || echo "")"
|
|
work="$(basename "$abs" 2>/dev/null || echo "root")"
|
|
if [ -z "$parent" ] || [ "$parent" = "/" ] || [ "$parent" = "." ]; then
|
|
parent="workspace"
|
|
fi
|
|
if [ -z "$work" ] || [ "$work" = "/" ] || [ "$work" = "." ]; then
|
|
work="root"
|
|
fi
|
|
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
|
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
|
|
slug="$(printf '%s' "$slug" | sed 's/^-*//')"
|
|
if [ -z "$slug" ]; then
|
|
slug="ws"
|
|
fi
|
|
printf 'mam-%s' "$slug"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_gen_uuid
|
|
# ---------------------------------------------------------------------------
|
|
mam_gen_uuid() {
|
|
if command -v uuidgen >/dev/null 2>&1; then
|
|
uuidgen | tr 'A-Z' 'a-z'
|
|
else
|
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_abs_workspace <path>
|
|
# ---------------------------------------------------------------------------
|
|
mam_abs_workspace() {
|
|
local p="${1:-}"
|
|
( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_workspace_key <workspace>
|
|
# ---------------------------------------------------------------------------
|
|
mam_workspace_key() {
|
|
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_session_name <workspace> <agent> [role]
|
|
# ---------------------------------------------------------------------------
|
|
derive_session_name() {
|
|
local workspace="${1:-$PWD}" agent="${2:-}" role="${3:-creator}"
|
|
role=$(echo "$role" | tr '[:upper:]' '[:lower:]')
|
|
local base_slug
|
|
base_slug="$(derive_workspace_slug "$workspace")"
|
|
local slug="${base_slug#mam-}"
|
|
if [ -n "$agent" ]; then
|
|
printf '%s-%s-%s' "$slug" "$role" "$agent"
|
|
else
|
|
printf '%s-%s-' "$slug" "$role"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# env_python <yaml_path> [KEY=VALUE ...] (Python source read from stdin)
|
|
#
|
|
# Run python3 with the source supplied on stdin via a *quoted* heredoc, so the
|
|
# shell never interpolates the source. All values are passed through the
|
|
# environment (YAML_PATH plus any KEY=VALUE pairs). Untrusted data (workspace
|
|
# paths, capture-pane text) must travel as env vars and be read via os.environ
|
|
# inside the script — never spliced into the source. Read-only by convention;
|
|
# use atomic_dump_yaml when you need to write the YAML.
|
|
# ---------------------------------------------------------------------------
|
|
_validate_env_key() {
|
|
local key="$1"
|
|
if [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
|
echo "ERROR: Invalid environment variable name: $key" >&2
|
|
return 1
|
|
fi
|
|
case "$key" in
|
|
LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PYTHONHOME|PYTHONINSPECT|PYTHONSTARTUP)
|
|
echo "ERROR: Blocked environment variable: $key" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
env_python() {
|
|
local yaml_path="$1"; shift
|
|
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
local key="${1%%=*}"
|
|
_validate_env_key "$key" || return 1
|
|
envs+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
local pybin
|
|
pybin="$(_delegate_py_bin)"
|
|
env "${envs[@]}" "$pybin" - "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# atomic_dump_yaml <yaml_path> [KEY=VALUE ...] (mutation source from stdin)
|
|
#
|
|
# The ONLY sanctioned way to write agent-sessions.yaml. It:
|
|
# 1. takes an exclusive SQLite BEGIN IMMEDIATE transaction lock on
|
|
# agent-sessions.db (serialises all writers)
|
|
# 2. loads the current state into `d` (seeds from YAML if DB is empty)
|
|
# 3. exec()s the caller's mutation source (sees d, yaml, os, datetime,
|
|
# timezone, glob, subprocess; reads values via os.environ). The mutation
|
|
# may print and may `raise SystemExit(n)` to abort *without* writing.
|
|
# 4. validates the resulting schema
|
|
# 5. backs up to <yaml_path>.bak, then writes YAML atomically (temp + os.replace)
|
|
# when a session transitions to a finished state.
|
|
#
|
|
# The mutation source is passed via env and exec()'d — it is never string
|
|
# spliced and untrusted data never lands in Python source (P0-B / P1-B).
|
|
# ---------------------------------------------------------------------------
|
|
# Check if the workspace is on NFS — locking behaves differently on NFS
|
|
_check_is_nfs() {
|
|
local f="$1"
|
|
local mountpoint
|
|
mountpoint="$(df --output=target "$f" 2>/dev/null | tail -1)"
|
|
if [ -z "$mountpoint" ]; then
|
|
mountpoint="$(df -P "$f" 2>/dev/null | tail -1 | awk '{print $6}')"
|
|
fi
|
|
if [ -n "$mountpoint" ] && mount | grep -i -q -E "$mountpoint.*(nfs|cifs|smb|sshfs)"; then
|
|
return 0 # is NFS
|
|
fi
|
|
return 1 # not NFS
|
|
}
|
|
|
|
atomic_dump_yaml() {
|
|
local yaml_path="$1"; shift
|
|
if [ -z "${MAM_IS_NFS:-}" ]; then
|
|
if _check_is_nfs "$(dirname "$yaml_path")"; then
|
|
export MAM_IS_NFS="true"
|
|
echo "WARNING: $(dirname "$yaml_path") appears to be a network filesystem (NFS/CIFS/SSHFS)." >&2
|
|
echo "WARNING: SQLite journal_mode automatically falls back to DELETE." >&2
|
|
else
|
|
export MAM_IS_NFS="false"
|
|
fi
|
|
fi
|
|
|
|
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "MAM_IS_NFS=$MAM_IS_NFS" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
local key="${1%%=*}"
|
|
_validate_env_key "$key" || return 1
|
|
envs+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
local mutation=""; mutation="$(cat)"
|
|
local pybin; pybin="$(_delegate_py_bin)"
|
|
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" "$pybin" - <<'PYEOF'
|
|
from lib_py.atomic_yaml import atomic_dump_yaml_main
|
|
atomic_dump_yaml_main()
|
|
PYEOF
|
|
}
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
|
|
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
|
|
# ---------------------------------------------------------------------------
|
|
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
|
|
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
|
|
else
|
|
echo "ERROR: lib_py/verify_session.py missing" >&2
|
|
return 1 2>/dev/null || exit 1
|
|
fi
|
|
|
|
verify_session_uuid() {
|
|
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
|
|
WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, json
|
|
from lib_py.verify_session import verify_session_uuid
|
|
ws = os.environ['WS_ABS']
|
|
agent = os.environ['AGENT']
|
|
uuid = os.environ['UUID']
|
|
row_str = os.environ.get('ROW_JSON', '')
|
|
row = json.loads(row_str) if row_str else {}
|
|
mode = os.environ.get('MODE', 'discover')
|
|
if verify_session_uuid(ws, agent, uuid, row, mode=mode):
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# verify_tui_viewport <session_name> <agent> <workspace>
|
|
#
|
|
# Viewport matching verification:
|
|
# | Return | Meaning | Action Required |
|
|
# |---|---|---|
|
|
# | 0 | Viewport matches workspace | Pin UUID, last_visible_status='pinned', drift class C |
|
|
# | 1 | Viewport mismatch | Do not pin, log C-warn, last_visible_status unchanged |
|
|
# | 2 | Viewport check unavailable | Pin UUID via stage 1-3 only, log C-degraded |
|
|
#
|
|
# Returns:
|
|
# 0 = verified match
|
|
# 1 = mismatch
|
|
# 2 = check not possible (capture failed, session gone)
|
|
verify_tui_viewport() {
|
|
local session_name="$1" agent="$2" workspace="$3"
|
|
local abs; abs="$(mam_abs_workspace "$workspace")"
|
|
local base; base="$(basename "$abs")"
|
|
|
|
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
|
|
return 2
|
|
fi
|
|
|
|
local pane_content
|
|
pane_content=$(_pane_capture "$session_name" 2>/dev/null || true)
|
|
if [ -z "$pane_content" ]; then
|
|
return 2
|
|
fi
|
|
|
|
local flat base_flat
|
|
flat=$(printf '%s' "$pane_content" | tr -d '[:space:]')
|
|
base_flat=$(printf '%s' "$base" | tr -d '[:space:]')
|
|
if printf '%s' "$flat" | grep -Fq "$base_flat"; then
|
|
return 0
|
|
fi
|
|
if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then
|
|
return 1
|
|
fi
|
|
return 2
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# find_workspace_uuid <workspace> <agent>
|
|
#
|
|
# Workspace-SCOPED resolution of the resume UUID (P0-C). Resolution order:
|
|
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
|
|
# (claude_session_id_own / agy_conversation_id_own)
|
|
# 2) on-disk scan scoped to this workspace, via the agent adapter's discover()
|
|
# Prints the UUID on stdout (empty line if none). Always exits 0.
|
|
# ---------------------------------------------------------------------------
|
|
find_workspace_uuid() {
|
|
local workspace="$1" agent="$2" session_name="${3:-}"
|
|
local abs; abs="$(mam_abs_workspace "$workspace")"
|
|
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
from lib_py.workspace_uuid import find_workspace_uuid_main
|
|
find_workspace_uuid_main()
|
|
PYEOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_session_ids <stop|resume> [args...]
|
|
# Issue #63: stop/purge/resume conversation-id decision (lib_py.agents.session_ids).
|
|
# ---------------------------------------------------------------------------
|
|
mam_session_ids() {
|
|
env_python "$AGENT_SESSIONS_YAML" "$@" <<'PYEOF'
|
|
import sys
|
|
from lib_py.agents.session_ids import main
|
|
sys.exit(main(sys.argv[1:]))
|
|
PYEOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_id_order_wait <workspace> <agent> <session> [uuid]
|
|
# Issue #63: same-cwd creation/resume ordering for post-discovered agents.
|
|
# ---------------------------------------------------------------------------
|
|
mam_id_order_wait() {
|
|
local workspace="$1" agent="$2" session_name="$3" uuid="${4:-}"
|
|
local abs; abs="$(mam_abs_workspace "$workspace")"
|
|
env_python "$AGENT_SESSIONS_YAML" wait --cwd "$abs" --agent "$agent" \
|
|
--session "$session_name" --owner-pid "$$" --uuid "$uuid" \
|
|
--herdr-server "${HERDR_SESSION_NAME:-}" <<'PYEOF'
|
|
import sys
|
|
from lib_py.agents.id_order import main
|
|
sys.exit(main(sys.argv[1:]))
|
|
PYEOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session isolation — Universal Global Config (Option B, P3-1)
|
|
#
|
|
# Config-home isolation (.mam/agent_homes/<uuid>/) and legacy isolation.root
|
|
# row consumers were completely deprecated and removed in favor of:
|
|
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
|
|
# ~/.hermes user configuration and credential stores.
|
|
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
|
|
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_already_stopped <session_name>
|
|
#
|
|
# Exits 0 if the row's status is 'stopped' (printing "stopped_at=<ts>" on
|
|
# stdout), 1 otherwise (including not-found). Used for idempotency: a second
|
|
# stop on an already-stopped session is a no-op.
|
|
# ---------------------------------------------------------------------------
|
|
is_already_stopped() {
|
|
local session_name="$1"
|
|
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, yaml, sqlite3, json
|
|
name = os.environ['SESSION_NAME']
|
|
yaml_path = os.environ['YAML_PATH']
|
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
try:
|
|
if os.path.exists(db_path):
|
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
has_sessions_table = False
|
|
try:
|
|
row = conn.execute('SELECT status, data FROM sessions WHERE name=?', (name,)).fetchone()
|
|
if row:
|
|
status, s_data_str = row[0], row[1]
|
|
if status == 'stopped':
|
|
s = json.loads(s_data_str)
|
|
print(f"stopped_at={s.get('stopped_at', '?')}")
|
|
raise SystemExit(0)
|
|
has_sessions_table = True
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
if not has_sessions_table:
|
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
if row:
|
|
d = json.loads(row[0])
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == name and s.get('status') == 'stopped':
|
|
print(f"stopped_at={s.get('stopped_at', '?')}")
|
|
raise SystemExit(0)
|
|
conn.close()
|
|
raise SystemExit(1)
|
|
elif os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == name and s.get('status') == 'stopped':
|
|
print(f"stopped_at={s.get('stopped_at', '?')}")
|
|
raise SystemExit(0)
|
|
except Exception:
|
|
pass
|
|
raise SystemExit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# multi-agent-mux-delegate-job integration helpers
|
|
#
|
|
# All paths are resolved relative to lib.sh's own location (BASH_SOURCE), so the
|
|
# skill tree is relocatable — no hardcoded absolute paths (review item 6).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# _delegate_py_bin — echo the virtualenv python (walk up from .agents/skills/), else python3.
|
|
_delegate_py_bin() {
|
|
# Return cached result if available
|
|
if [ -n "${AGENT_PYTHON_BIN:-}" ] && [ -x "$AGENT_PYTHON_BIN" ]; then
|
|
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
|
|
fi
|
|
if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "$VIRTUAL_ENV/bin/python" ]; then
|
|
AGENT_PYTHON_BIN="$VIRTUAL_ENV/bin/python"
|
|
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
|
|
fi
|
|
local d
|
|
d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
while [ "$d" != "/" ] && [ -n "$d" ]; do
|
|
if [ -x "$d/.venv/bin/python" ]; then
|
|
AGENT_PYTHON_BIN="$d/.venv/bin/python"
|
|
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
|
|
fi
|
|
d="$(dirname "$d")"
|
|
done
|
|
AGENT_PYTHON_BIN="$(command -v python3 || echo python3)"
|
|
printf '%s\n' "$AGENT_PYTHON_BIN"
|
|
}
|
|
|
|
# _delegate_script <name> — echo the path to a multi-agent-mux-delegate-job script, resolved
|
|
# relative to .agents/skills/ (lib.sh dir). Empty if not found.
|
|
_delegate_script() {
|
|
local name="$1" skill_dir cand
|
|
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cand="$skill_dir/multi-agent-mux-delegate-job/scripts/$name"
|
|
if [ -f "$cand" ]; then printf '%s\n' "$cand"; return 0; fi
|
|
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)"
|
|
}
|
|
|
|
# _delegate_prompt_file <prompt> — write the prompt to a new file under the
|
|
# control root's .mam/ and echo its path. Issue #36: a prompt handed to an exec
|
|
# as one argv string is capped at 131,072 bytes; the caller removes the file.
|
|
_delegate_prompt_file() {
|
|
local dir="${WORKSPACE_ROOT:-$PWD}/.mam" f
|
|
mkdir -p "$dir" || return 1
|
|
f="$(mktemp "$dir/prompt-submit-XXXXXX")" || return 1
|
|
printf '%s' "$1" > "$f" || { rm -f "$f"; return 1; }
|
|
printf '%s\n' "$f"
|
|
}
|
|
|
|
# delegate_submit_job <prompt> <agent> <agent_session>
|
|
#
|
|
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
|
|
_delegate_submit_job_legacy() {
|
|
local prompt="$1" agent="$2" session="$3"
|
|
local py_bin registry_py prompt_file rc=0
|
|
py_bin="$(_delegate_py_bin)"
|
|
registry_py="$(_delegate_script registry.py)"
|
|
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
|
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
|
return 1
|
|
fi
|
|
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
|
|
"$py_bin" "$registry_py" register \
|
|
--prompt-file "$prompt_file" \
|
|
--agent "$agent" \
|
|
--agent-session "$session" || rc=$?
|
|
rm -f "$prompt_file"
|
|
return $rc
|
|
}
|
|
|
|
# delegate_publish_event <job_id> <event> [detail]
|
|
#
|
|
# Publish a lifecycle event to the multi-agent-mux-delegate-job registry. Consolidates the
|
|
# inline .venv-walk + publish_event.py blocks that were duplicated across
|
|
# create/delete/resume (review item 7). Non-fatal by contract: an empty job id,
|
|
# a missing script, or a broker failure never aborts the caller.
|
|
_delegate_publish_event_legacy() {
|
|
local job_id="$1" event="$2" detail="${3:-}"
|
|
local tok="${4:-}" att="${5:-}"
|
|
[ -n "$job_id" ] || return 0
|
|
local py_bin pub
|
|
py_bin="$(_delegate_py_bin)"
|
|
pub="$(_delegate_script publish_event.py)"
|
|
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
|
local -a extra=()
|
|
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
|
|
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
|
|
[ -n "$att" ] && extra+=(--attempt-id "$att")
|
|
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
|
}
|
|
|
|
# start_watchdog <job_id> [workdir]
|
|
# Spawns a watchdog process to monitor a delegate-job JOB in the background.
|
|
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
|
|
# limit we set) and exits automatically when the JOB reaches terminal state.
|
|
# Returns the watchdog PID via stdout.
|
|
_start_watchdog_legacy() {
|
|
local job_id="$1"
|
|
local workdir="${2:-$PWD}"
|
|
local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
|
|
local log_file="$workdir/.mam/multi-agent-mux-monitor.log"
|
|
|
|
if [ ! -f "$monitor_script" ]; then
|
|
echo "ERROR: monitor script not found: $monitor_script" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Check if reconcile.sh --subscribe is already running on this workspace
|
|
local pid
|
|
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
|
|
|
|
# Issue #19: MAM_NO_SUBSCRIBER_DAEMON=1 reports a running daemon but never
|
|
# starts one (test sandboxes; externally supervised monitors).
|
|
if [ -z "$pid" ] && [ "${MAM_NO_SUBSCRIBER_DAEMON:-0}" = "1" ]; then
|
|
echo "subscriber daemon not started (MAM_NO_SUBSCRIBER_DAEMON=1)" >&2
|
|
elif [ -z "$pid" ]; then
|
|
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
|
|
# and ensure it runs with $workdir as cwd to anchor relative log paths.
|
|
local orig_pwd="$PWD"
|
|
cd "$workdir" || return 1
|
|
nohup bash "$monitor_script" --subscribe --idle-timeout 0 </dev/null >> "$log_file" 2>&1 &
|
|
pid=$!
|
|
cd "$orig_pwd" || return 1
|
|
fi
|
|
|
|
# Byte-identical to the python body: nothing on stdout when no daemon runs.
|
|
if [ -n "$pid" ]; then
|
|
echo "$pid"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Python-backed delegation helpers (issue #5 phase 1).
|
|
#
|
|
# The bodies above are kept verbatim as _*_legacy and are still reachable with
|
|
# MAM_DELEGATION_PY=0, which is the instant rollback switch: no redeploy, no
|
|
# revert. They are removed in phase 4, once the python path has soaked.
|
|
#
|
|
# PYTHONPATH is passed explicitly (same pattern as env_python) so `-m` resolves
|
|
# lib_py from the tree this lib.sh belongs to -- that is what makes a frozen
|
|
# loop run frozen delegation code.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# _delegation_py <cli-args...>
|
|
# Run the delegation CLI from *this* tree.
|
|
#
|
|
# NOT `python -m`: for -m, sys.path[0] is the current directory, which therefore
|
|
# beats PYTHONPATH. A frozen loop whose cwd happened to contain a lib_py package
|
|
# would silently execute live code, losing B-13. An explicit insert of SKILL_DIR
|
|
# cannot be shadowed that way. cwd is deliberately left alone; the registry and
|
|
# audit-log paths resolve to MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR, then to
|
|
# $WORKSPACE_ROOT/.mam/..., and only then relative to it (Issue #15).
|
|
_delegation_py() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c '
|
|
import sys
|
|
sys.path.insert(0, sys.argv[1])
|
|
from lib_py.delegation.cli import main
|
|
sys.exit(main(sys.argv[2:]))
|
|
' "$SKILL_DIR" "$@"
|
|
}
|
|
|
|
# delegate_submit_job <prompt> <agent> <agent_session>
|
|
# Register a job. Prints the new JID on stdout and nothing else.
|
|
delegate_submit_job() {
|
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_submit_job_legacy "$@"; return $?; fi
|
|
local prompt="$1" agent="$2" session="$3"
|
|
local registry_py
|
|
registry_py="$(_delegate_script registry.py)"
|
|
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
|
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
|
return 1
|
|
fi
|
|
local prompt_file rc=0
|
|
prompt_file="$(_delegate_prompt_file "$prompt")" || { echo "ERROR: cannot write the prompt file" >&2; return 1; }
|
|
_delegation_py submit --prompt-file "$prompt_file" --agent "$agent" --agent-session "$session" || rc=$?
|
|
rm -f "$prompt_file"
|
|
return $rc
|
|
}
|
|
|
|
# delegate_publish_event <job_id> <event> [detail]
|
|
# Non-fatal by contract: an empty job id, a missing script tree, or a broker
|
|
# failure must never abort the caller.
|
|
delegate_publish_event() {
|
|
local job_id="$1" event="$2" detail="${3:-}"
|
|
local tok="${4:-}" att="${5:-}"
|
|
[ -n "$job_id" ] || return 0
|
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
|
|
local pub
|
|
pub="$(_delegate_script publish_event.py)"
|
|
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
|
local -a extra=()
|
|
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
|
|
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
|
|
[ -n "$att" ] && extra+=(--attempt-id "$att")
|
|
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
|
}
|
|
|
|
# delegate_publish_cmd <job_id> [dispatch_token] [attempt_id]
|
|
# The publish command an agent is told to run, shell-quoted, with absolute
|
|
# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree
|
|
# runs it as written). create_session's onboarding uses this; the delegate-job
|
|
# wrapper still builds its own (_pub_cmd), and S7l pins the two equal
|
|
# argument-for-argument, so both hand the agent the same shape:
|
|
# `--expect-dispatch-token T --attempt-id A` make publish_event write
|
|
# data.dispatch_token / data.attempt_id into the agent's own events, which is
|
|
# the only way a `started` can name the dispatch it answers (the trial-2
|
|
# finding: create_session's hand-rolled command omitted them, the agent's
|
|
# `started` carried no token, and the token-checking waiter could never be
|
|
# satisfied by a real agent). publish_event also CAS-checks that token against
|
|
# the record, so a stale dispatch's event is refused at the source.
|
|
delegate_publish_cmd() {
|
|
local job_id="$1" tok="${2:-}" att="${3:-}"
|
|
local py_bin pub_script pub_reg pub_logs
|
|
py_bin="$(_delegate_py_bin)"
|
|
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
|
|
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
|
|
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
|
|
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
|
|
pub_reg="${pub_reg:-.mam/jobs}"
|
|
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
|
|
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$pub_reg" 2>/dev/null || true)"
|
|
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
|
|
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
|
|
[ -n "$att" ] && argv+=(--attempt-id "$att")
|
|
local out
|
|
out="$(
|
|
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
|
printf '%q ' "${argv[@]}"
|
|
)"
|
|
printf '%s' "${out% }"
|
|
}
|
|
|
|
# delegate_job_status <job_id> — the record's status, empty if unreadable.
|
|
delegate_job_status() {
|
|
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
|
REC="$reg_dir/$1.json" python3 -c '
|
|
import json, os
|
|
try:
|
|
print(json.load(open(os.environ["REC"])).get("status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' 2>/dev/null || true
|
|
}
|
|
|
|
# delegate_started_recorded <job_id> [dispatch_token] [logs_dir]
|
|
# THE definition of "this dispatch's started is on disk", shared by
|
|
# delegate_wait_started (create_session's onboarding ack) and the delegate-job
|
|
# wrapper's _wait_started. Two sources, one rule:
|
|
#
|
|
# * <registry>/<job>.events.log - the local publisher's own line, whose
|
|
# data.dispatch_token must equal <dispatch_token> when one is given (a loop
|
|
# job keeps one events.log across attempts, and a stale attempt's started
|
|
# must not vouch for this one - review R1);
|
|
# * <logs_dir>/<job>/events.ndjson - the subscriber's audit record of a
|
|
# `started` it received, the only evidence a REMOTE agent (no shared disk)
|
|
# can leave. The subscriber verifies the HMAC and drops any event whose
|
|
# data.dispatch_token differs from the job's expected token before it
|
|
# writes this record (dispatch_verified), and writes dispatch_fingerprint
|
|
# = SHA-256 of that token. The file is appended to across a job's
|
|
# attempts, so the flag alone would let an earlier attempt's record
|
|
# acknowledge a later dispatch (review round 4): with <dispatch_token>
|
|
# given, a record counts only if it is dispatch_verified AND its
|
|
# fingerprint equals SHA-256(<dispatch_token>). A record with no
|
|
# fingerprint (written before this field existed) names no dispatch and
|
|
# is rejected on that path - fail closed. Without a token, any received
|
|
# started counts, as before.
|
|
#
|
|
# Never the registry status: a record leaves `pending` when another writer
|
|
# closes it or another dispatch claims it. Lines are parsed as JSON, never
|
|
# pattern-matched. rc 0 = recorded, 1 = not (yet).
|
|
delegate_logs_dir() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$1" 2>/dev/null
|
|
}
|
|
|
|
delegate_started_recorded() {
|
|
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
|
|
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
|
# Issue #57: the audit log is resolved by the one rule (next to the registry
|
|
# unless DELEGATE_JOB_LOGS_DIR says otherwise); no bash copy of the default.
|
|
# If that resolution fails, only the audit check is skipped: the registry's
|
|
# events.log is still evidence.
|
|
[ -n "$logs_dir" ] || logs_dir="$(delegate_logs_dir "$reg_dir")" || logs_dir=""
|
|
local ev_log="$reg_dir/$job_id.events.log"
|
|
local audit=""
|
|
[ -n "$logs_dir" ] && audit="$logs_dir/$job_id/events.ndjson"
|
|
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
|
|
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
|
|
import hashlib, json, os, sys
|
|
tok = os.environ.get("EV_TOK", "")
|
|
fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else ""
|
|
|
|
def lines(path):
|
|
if not os.path.isfile(path):
|
|
return
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
try:
|
|
e = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
if isinstance(e, dict):
|
|
yield e
|
|
|
|
for e in lines(os.environ["EV_LOG"]):
|
|
if e.get("event") != "started":
|
|
continue
|
|
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
|
if not tok or data.get("dispatch_token") == tok:
|
|
sys.exit(0)
|
|
for e in lines(os.environ["EV_AUDIT"]):
|
|
if e.get("event") != "received" or e.get("source_event") != "started":
|
|
continue
|
|
if not tok:
|
|
sys.exit(0)
|
|
if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp:
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
' 2>/dev/null
|
|
}
|
|
|
|
# mam_int / mam_int_knob (Issue #26) live in lib_int.sh, shared with the
|
|
# scripts that run without lib.sh.
|
|
# shellcheck source=lib_int.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"
|
|
|
|
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
|
|
#
|
|
# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the
|
|
# screen. The evidence is delegate_started_recorded (above); the registry's
|
|
# status is deliberately NOT evidence - a record leaves `pending` when another
|
|
# writer closes it or another dispatch claims it (review R1). The local
|
|
# publisher writes the log line before it syncs the status, so nothing that
|
|
# really started is missed. Returns 0 on the evidence, 1 if it did not appear
|
|
# within T. Never re-injects: the single-injection contract is fail-closed.
|
|
delegate_wait_started() {
|
|
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
|
|
[ -n "$job_id" ] || return 1
|
|
ack_s=$(mam_int "$ack_s" 0) || { echo "WARNING: onboarding ack timeout ${2-} ignored (not a decimal integer >= 0); using 180" >&2; ack_s=180; }
|
|
local deadline logs_dir
|
|
deadline=$(( $(date +%s) + ack_s ))
|
|
# Issue #57: resolve the audit-log root once, not once per 1 s poll.
|
|
logs_dir="$(delegate_logs_dir "${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}")" || logs_dir=""
|
|
while :; do
|
|
if delegate_started_recorded "$job_id" "$tok" "$logs_dir"; then
|
|
return 0
|
|
fi
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# delegate_wait_terminal <job_id> <timeout_s>
|
|
# Issue #50: prints how the job ended - completed|error|cancelled - or
|
|
# `timeout` when it has not ended after <timeout_s>, or `missing` when its
|
|
# record is gone. The decision is lib_py.delegation.wrapper wait-terminal.
|
|
delegate_wait_terminal() {
|
|
local job_id="$1" timeout_s="$2"
|
|
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper wait-terminal \
|
|
--job "$job_id" --registry-dir "$reg_dir" --timeout "$timeout_s" --watch-pid "$$"
|
|
}
|
|
|
|
# delegate_autopin_completed <job_id> [dispatch_token] [registry_dir]
|
|
# Issue #40: once this side has seen the job `completed`, pin the session's
|
|
# post-discovered id (lib_py.agents.autopin decides and writes; remote, human
|
|
# and pre-assigned sessions are left alone, one job/token/attempt is handled
|
|
# once). Reports on stderr; never changes the job's result or this exit code.
|
|
delegate_autopin_completed() {
|
|
local job_id="$1" token="${2:-}" reg_dir="${3:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" HOME_DIR="${HOME_DIR:-$HOME}" LIB_SH="$SKILL_DIR/lib.sh" \
|
|
"$(_delegate_py_bin)" -m lib_py.agents.autopin --job "$job_id" --registry-dir "$reg_dir" \
|
|
--yaml "$AGENT_SESSIONS_YAML" ${token:+--expect-dispatch-token "$token"} >&2 || true
|
|
}
|
|
|
|
# delegate_onboard_exit <status>
|
|
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
|
delegate_onboard_exit() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper onboard-exit "$1"
|
|
}
|
|
|
|
# ensure_subscriber_daemon <job_id> [workdir]
|
|
# Start the monitor subscriber for this workspace unless one is already running.
|
|
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
|
ensure_subscriber_daemon() {
|
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
|
|
_delegation_py ensure-daemon --job "$1" --workdir "${2:-$PWD}"
|
|
}
|
|
|
|
# start_watchdog — retained name; create_session.sh and operator docs use it.
|
|
start_watchdog() { ensure_subscriber_daemon "$@"; }
|
|
|
|
# wait_for_tui_ready <session_name> <agent>
|
|
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
|
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
|
|
wait_for_tui_ready() {
|
|
local sess="$1" agent="$2"
|
|
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
|
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
|
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
|
local prompt="${MAM_INPUT_PROMPT:-}"
|
|
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
|
local modal_tokens="${MAM_MODAL_TOKENS:-}"
|
|
|
|
# Self-contained resolution: fetch facts in 1 call if not already in environment
|
|
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
|
|
local _facts=""
|
|
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
|
if [ -n "$_facts" ]; then
|
|
eval "$_facts"
|
|
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
|
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
|
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
|
prompt="${MAM_INPUT_PROMPT:-}"
|
|
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
|
modal_tokens="${MAM_MODAL_TOKENS:-}"
|
|
fi
|
|
fi
|
|
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
|
|
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
|
|
strong_tokens="$MAM_READY_TOKENS"
|
|
fi
|
|
|
|
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
|
|
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
|
return 1
|
|
fi
|
|
|
|
local empty_streak=0
|
|
local empty_giveup="$(mam_int_knob MAM_READY_EMPTY_GIVEUP 5 1)"
|
|
local modal_pat="$_MAM_MODAL_TOKENS"
|
|
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
|
|
|
local i content tail_lines
|
|
for i in {1..30}; do
|
|
# 1. Single capture per iteration (T-3)
|
|
content=$(_pane_capture "$sess")
|
|
|
|
# Consecutive blank capture check (C-6)
|
|
if [ -z "$content" ]; then
|
|
empty_streak=$((empty_streak + 1))
|
|
if [ "$empty_streak" -ge "$empty_giveup" ]; then
|
|
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
|
|
return 2
|
|
fi
|
|
sleep 1
|
|
continue
|
|
fi
|
|
empty_streak=0
|
|
|
|
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
|
|
# handle it and do NOT declare ready on this iteration.
|
|
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
|
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
|
handle_startup_dialogs "$sess" 1 "$agent" || true
|
|
sleep 1
|
|
continue
|
|
fi
|
|
|
|
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
|
|
local s_matched=false
|
|
local w_matched=false
|
|
local c_matched=false
|
|
|
|
# S-layer: Strong ready token match
|
|
if [ -n "$strong_tokens" ]; then
|
|
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
|
|
s_matched=true
|
|
fi
|
|
fi
|
|
|
|
# S-layer: Placeholder match (must be >= 8 characters)
|
|
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
|
|
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
|
|
s_matched=true
|
|
fi
|
|
fi
|
|
|
|
# W-layer: Weak ready token match
|
|
if [ -n "$weak_tokens" ]; then
|
|
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
|
|
w_matched=true
|
|
fi
|
|
fi
|
|
|
|
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
|
|
if [ -n "$prompt" ]; then
|
|
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
|
|
c_matched=true
|
|
fi
|
|
fi
|
|
if [ -n "$rule_pattern" ]; then
|
|
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
|
|
c_matched=true
|
|
fi
|
|
fi
|
|
|
|
# Decision: S OR (W AND C)
|
|
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
|
|
echo "✅ $agent TUI detected ready."
|
|
return 0
|
|
fi
|
|
|
|
# 4. Startup dialog & hint resolution if not ready
|
|
handle_startup_dialogs "$sess" 1 "$agent" || true
|
|
sleep 1
|
|
done
|
|
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
|
return 1
|
|
}
|
|
|
|
# inject_instructions <session_name> <instructions> [job_id]
|
|
# Injects instructions into the herdr session using paste-buffer and C-m.
|
|
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
|
|
inject_instructions() {
|
|
send_keys_safe "$1" "$2" "${3:-onboard}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt-lock safe delivery (FW-W2). Keys are sent on evidence, not timers.
|
|
# send_keys_safe returns 0 only if the text was verifiably submitted.
|
|
# Exit codes: 1=pane never quiesced 2=dialog blocking input
|
|
# 3=paste not visible 4=Enter not accepted
|
|
# Callers MUST handle non-zero.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Server-aware herdr (same isolation rule as inject_instructions).
|
|
_sks_herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
_pane_capture() {
|
|
local raw
|
|
raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")"
|
|
if [ -z "$raw" ]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
python3 -c '
|
|
import sys, json
|
|
raw = sys.stdin.read()
|
|
try:
|
|
data = json.loads(raw)
|
|
text = data.get("result", {}).get("read", {}).get("text")
|
|
if text is not None:
|
|
print(text, end="")
|
|
else:
|
|
print(raw, end="")
|
|
except Exception:
|
|
print(raw, end="")
|
|
' <<< "$raw"
|
|
}
|
|
|
|
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
|
|
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
|
|
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
|
|
|
|
# _sks_agent_status <sess> [target] — herdr agent_status, empty when unavailable.
|
|
_sks_agent_status() {
|
|
local raw
|
|
raw="$(_sks_herdr agent get "${2:-$1}" 2>/dev/null || _sks_herdr agent get "$1" 2>/dev/null || true)"
|
|
[ -n "$raw" ] || { echo ""; return 0; }
|
|
"$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$raw" 2>/dev/null || true
|
|
}
|
|
|
|
# _sks_job_started <job_id> — Issue #18 delivery signal: the job's own
|
|
# `started` (registry status left `pending`, or a started line in the job's
|
|
# events.log). Reads the registry the wrapper exports (SKS_REGISTRY_DIR), else
|
|
# MAM_JOBS_DIR, else $WORKSPACE_ROOT/.mam/jobs. "adhoc" (no job) is never started.
|
|
_sks_job_started() {
|
|
local job_id="${1:-adhoc}" rd
|
|
[ -n "$job_id" ] && [ "$job_id" != "adhoc" ] || return 1
|
|
rd="${SKS_REGISTRY_DIR:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
|
|
"$(_delegate_py_bin)" - "$rd" "$job_id" <<'PYEOF'
|
|
import json, os, sys
|
|
rd, jid = sys.argv[1], sys.argv[2]
|
|
rec = os.path.join(rd, jid + ".json")
|
|
try:
|
|
st = json.load(open(rec)).get("status")
|
|
if st and st != "pending":
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
log = os.path.join(rd, jid + ".events.log")
|
|
try:
|
|
with open(log) as fh:
|
|
for line in fh:
|
|
try:
|
|
if json.loads(line).get("event") == "started":
|
|
sys.exit(0)
|
|
except Exception:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# _wait_session_gone <sess> [max_sec=5]
|
|
# Reactive loop to wait until a herdr session goes away (happy path returns early).
|
|
_wait_session_gone() {
|
|
local sess="$1" max="${2:-5}" i
|
|
for ((i = 0; i < max * 4; i++)); do
|
|
_sks_herdr has-session -t "$sess" 2>/dev/null || return 0
|
|
sleep 0.25
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# _pane_quiescent <sess> [tries=20] [interval=0.5] # empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)
|
|
# Renderer settled = two consecutive identical non-empty captures.
|
|
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
|
# Returns 0 if renderer settled (two identical non-empty captures).
|
|
# Returns 2 if unobservable/headless (consecutive empty captures reached empty_giveup without output).
|
|
# Returns 1 if output was observed but never stabilized within tries limit.
|
|
_pane_quiescent() {
|
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
|
local saw_output=0 empty_streak=0
|
|
local empty_giveup="$(mam_int_knob SKS_EMPTY_GIVEUP 3 1)"
|
|
for ((i = 0; i < tries; i++)); do
|
|
cur=$(_pane_capture "$sess")
|
|
if [ -z "$cur" ]; then
|
|
empty_streak=$((empty_streak + 1))
|
|
[ "$saw_output" = "0" ] && [ "$empty_streak" -ge "$empty_giveup" ] && return 2
|
|
sleep "$interval"
|
|
continue
|
|
fi
|
|
saw_output=1
|
|
empty_streak=0
|
|
[ "$cur" = "$prev" ] && return 0
|
|
prev="$cur"
|
|
sleep "$interval"
|
|
done
|
|
[ "$saw_output" = "0" ] && return 2
|
|
return 1
|
|
}
|
|
|
|
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
|
|
# permission / OAuth / list-selection), checked in the bottom 8 pane lines
|
|
# only (dialogs render near the input area; conversation text above must not
|
|
# trigger this). Tokens must NOT appear on normal idle prompt screens.
|
|
_pane_dialog_open() {
|
|
local extra="${MAM_MODAL_TOKENS:-}"
|
|
local pat="$_MAM_MODAL_TOKENS"
|
|
[ -n "$extra" ] && pat="${pat}|${extra}"
|
|
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
|
|
}
|
|
|
|
# wait_for_agent_idle <sess> [timeout_ms]
|
|
# Phase 2 사전 TUI 유휴 핸드셰이크:
|
|
# 0: idle / done (성공)
|
|
# 1: agent still working (최종 타임아웃)
|
|
# 2: blocked (대화창/모달 차단)
|
|
# 3: unknown (휴리스틱 폴백 신호)
|
|
wait_for_agent_idle() {
|
|
local sess="${1#herdr:}"
|
|
local timeout_ms="${2:-${SKS_IDLE_TIMEOUT_MS:-120000}}"
|
|
|
|
if [ "${SKS_IDLE_HANDSHAKE:-1}" = "0" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local target
|
|
target=$(_sanitize_herdr_agent_name "$sess") || return 3
|
|
|
|
local raw_status="" agent_st=""
|
|
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
|
if [ -n "$raw_status" ]; then
|
|
agent_st="$("$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$raw_status" 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [ -z "$agent_st" ] || [ "$agent_st" = "unknown" ]; then
|
|
return 3
|
|
fi
|
|
if [ "$agent_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
if [ "$agent_st" = "idle" ] || [ "$agent_st" = "done" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local max_retries="$(mam_int_knob SKS_IDLE_RETRIES 2 0)"
|
|
local backoff_s="$(mam_int_knob SKS_IDLE_RETRY_BACKOFF_S 10 0)"
|
|
local try=0
|
|
while [ "$try" -le "$max_retries" ]; do
|
|
local watcher_out="" watcher_rc=0
|
|
watcher_out="$("$(_delegate_py_bin)" -m lib_py.loop.watcher wait "$target" \
|
|
--session "${HERDR_SESSION_NAME:-default}" \
|
|
--until idle --until done --until blocked \
|
|
--timeout-ms "$timeout_ms" 2>/dev/null)" || watcher_rc=$?
|
|
|
|
local parsed_st=""
|
|
if [ -n "$watcher_out" ]; then
|
|
parsed_st="$("$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print(d.get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$watcher_out" 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [ $watcher_rc -eq 0 ]; then
|
|
if [ "$parsed_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
return 0
|
|
elif [ $watcher_rc -eq 2 ]; then
|
|
if [ "$parsed_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
if [ "$try" -lt "$max_retries" ]; then
|
|
sleep "$backoff_s"
|
|
try=$((try + 1))
|
|
continue
|
|
else
|
|
return 1
|
|
fi
|
|
else
|
|
return 3
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# send_keys_safe <sess> <text> [job_id]
|
|
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
|
|
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
|
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
|
# Escape per poll and re-check. NEVER a blind Enter.
|
|
# 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission.
|
|
# An accepted submission whose status watch timed out is confirmed by polling
|
|
# for SKS_DELIVERY_GRACE_S (default 15 s) - see ISSUE-14 below.
|
|
# 4. Paste via unique buffer; verify the text landed (marker visible).
|
|
# 5. Submit C-m; verify submission (marker left the input area AND the pane
|
|
# changed); retry up to 3 times.
|
|
send_keys_safe() {
|
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
|
local pre_submit deadline try
|
|
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
|
|
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
|
|
|
|
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
|
|
local _sks_agent=""
|
|
case "$sess" in
|
|
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
|
|
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
|
|
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
|
|
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
|
|
*-opencode|*-opencode-[0-9]*|opencode|opencode-[0-9]*) _sks_agent="opencode" ;;
|
|
*-codex|*-codex-[0-9]*|codex|codex-[0-9]*) _sks_agent="codex" ;;
|
|
esac
|
|
if [ -n "$_sks_agent" ]; then
|
|
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
|
|
fi
|
|
fi
|
|
|
|
local _q_rc=0
|
|
_pane_quiescent "$sess" "$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
|
if [ "$_q_rc" = "1" ]; then
|
|
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ "$_q_rc" != "2" ]; then
|
|
deadline=$(( $(date +%s) + $(mam_int_knob SKS_DIALOG_TIMEOUT 30 0) ))
|
|
while _pane_dialog_open "$sess"; do
|
|
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
|
_sks_herdr send-keys -t "$sess" Escape
|
|
sleep 1
|
|
fi
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
|
return 2
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
|
|
local agent_target
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess") || return 1
|
|
|
|
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
|
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
|
local b_deadline=$(( $(date +%s) + $(mam_int_knob SKS_BUSY_TIMEOUT 120 0) ))
|
|
local still_busy=1
|
|
while [ "$(date +%s)" -lt "$b_deadline" ]; do
|
|
local tail_pane
|
|
tail_pane=$(_pane_tail "$sess" 8)
|
|
if ! printf '%s\n' "$tail_pane" | grep -Fq "esc to interrupt" && ! printf '%s\n' "$tail_pane" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
|
still_busy=0
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$still_busy" -eq 1 ]; then
|
|
echo "send_keys_safe: pane still busy after SKS_BUSY_TIMEOUT ($sess)" >&2
|
|
return 4
|
|
fi
|
|
fi
|
|
|
|
# Native herdr 0.8+ fast path: agent prompt handles atomic submission
|
|
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
|
|
local p_out="" p_rc=0
|
|
local _p_tmp
|
|
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$")
|
|
if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then
|
|
rm -f "$_p_tmp" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
p_rc=$?
|
|
p_out=$(cat "$_p_tmp" 2>/dev/null || true)
|
|
rm -f "$_p_tmp" 2>/dev/null || true
|
|
|
|
if printf '%s\n' "$p_out" | grep -Fq "agent_not_found"; then
|
|
local _p_tmp2
|
|
_p_tmp2=$(mktemp 2>/dev/null || echo "/tmp/sks_p2_${sess}_$$")
|
|
if _sks_herdr agent prompt "$sess" "$text" --wait --until working --timeout 5000 >"$_p_tmp2" 2>&1; then
|
|
rm -f "$_p_tmp2" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
p_rc=$?
|
|
p_out=$(cat "$_p_tmp2" 2>/dev/null || true)
|
|
rm -f "$_p_tmp2" 2>/dev/null || true
|
|
fi
|
|
if printf '%s\n' "$p_out" | grep -Fq "agent_blocked"; then
|
|
echo "send_keys_safe: agent blocked ($sess)" >&2
|
|
return 2
|
|
fi
|
|
|
|
# Only fall through to legacy paste-buffer if agent prompt command itself is not supported
|
|
# or if target agent is not found (generic pane / terminal)
|
|
if printf '%s\n' "$p_out" | grep -Eq "unknown command|unrecognized command|no such command|unexpected argument|unrecognized option|agent_not_found"; then
|
|
: # Fall through to legacy paste buffer
|
|
else
|
|
# Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.),
|
|
# do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
|
|
sleep 2
|
|
if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then
|
|
return 0
|
|
fi
|
|
# ISSUE-14 / ISSUE-18: `timeout` / `agent_prompt_stalled` mean herdr
|
|
# ACCEPTED the submission and sent the input - only its post-submit
|
|
# status watch came up empty (codex under herdr 0.9.0 never reports
|
|
# `working`). For an accepted submission keep observing for
|
|
# SKS_DELIVERY_GRACE_S (default 15) and take a *protocol* signal only:
|
|
# agent_status working, or the job's own `started` in the registry
|
|
# (_sks_job_started). Pane changes and busy tokens are no longer
|
|
# delivery evidence (Issue #18: they are CLI-version-specific screen
|
|
# scraping that a headless or remote agent never produces). No signal
|
|
# still fails closed (rc 4); the wrapper then waits for `started` up to
|
|
# the job's ack timeout T before finalising the inject as failed.
|
|
if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then
|
|
local g_deadline=$(( $(date +%s) + $(mam_int_knob SKS_DELIVERY_GRACE_S 15 0) ))
|
|
while [ "$(date +%s)" -lt "$g_deadline" ]; do
|
|
[ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0
|
|
_sks_job_started "$job_id" && return 0
|
|
sleep 1
|
|
done
|
|
fi
|
|
echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
|
|
return 4
|
|
fi
|
|
else
|
|
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Fallback: paste buffer submission. Compute verification markers on demand.
|
|
local marker marker_norm
|
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
|
# the properly-decoded rendered pane text) of the last non-empty line.
|
|
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')")
|
|
# Whitespace-normalized copy for matching: some TUIs don't just soft-wrap
|
|
# with a bare newline — they add a leading-space "hanging indent" on the
|
|
# continuation line too, so removing only '\n' still leaves an extra space
|
|
# that breaks an exact literal match. Matching with all whitespace collapsed
|
|
# out sidesteps wrap formatting entirely, whatever shape it takes.
|
|
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
|
|
|
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
|
|
_sks_herdr set-buffer -b "$sks_buf" "$text"
|
|
local _paste_rc=0
|
|
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
|
|
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
|
|
if [ "$_paste_rc" != "0" ]; then
|
|
echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
|
|
return 3
|
|
fi
|
|
local was_popup=0
|
|
if [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]] || [[ "$sess" =~ "grok" ]]; then
|
|
# Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
|
|
true
|
|
else
|
|
sleep 0.5
|
|
local pane_content
|
|
pane_content=$(_pane_capture "$sess")
|
|
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
|
was_popup=1
|
|
# Strip ALL whitespace before matching — the rendered pane soft-wraps long
|
|
# lines at the terminal width (and some TUIs add indentation on the
|
|
# continuation line too), which can split/reformat the marker across two
|
|
# visual lines and make a literal grep miss it even though the paste landed.
|
|
elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then
|
|
echo "send_keys_safe: paste not visible ($sess)" >&2
|
|
return 3
|
|
fi
|
|
fi
|
|
|
|
for try in 1 2 3; do
|
|
pre_submit=$(_pane_capture "$sess")
|
|
_sks_herdr send-keys -t "$sess" C-m
|
|
sleep "$try"
|
|
local cur_content
|
|
cur_content=$(_pane_capture "$sess")
|
|
# Hardened Submission Checks: exclude busy tokens if already present in pre_submit
|
|
local pre_busy=0
|
|
if printf '%s\n' "$pre_submit" | grep -Fq "esc to interrupt" || printf '%s\n' "$pre_submit" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
|
pre_busy=1
|
|
fi
|
|
if [ "$pre_busy" -eq 0 ] && ( printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing" ); then
|
|
return 0
|
|
fi
|
|
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
elif [ "$was_popup" = "1" ] && \
|
|
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
|
[ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
fi
|
|
done
|
|
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
|
|
return 4
|
|
}
|
|
|
|
# _mam_load_dialog_facts <agent>
|
|
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell, and the
|
|
# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller
|
|
# has not set them. `facts` is an eval contract (F8); evaluating all of it here
|
|
# would clobber tokens a caller deliberately set, so only these are taken, and
|
|
# the tokens only fill a gap. Without them the handler could never recognise a
|
|
# ready screen when called on its own - resume_session.sh does exactly that,
|
|
# with no wait_for_tui_ready before it - and idled through its whole timeout on
|
|
# every resume (review B-1). An agent the adapters do not know keeps an empty
|
|
# list and no tokens.
|
|
_mam_load_dialog_facts() {
|
|
local agent="$1" _f="" _d="" _s="" _r=""
|
|
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
|
|
return 0
|
|
fi
|
|
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f=""
|
|
_d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')"
|
|
_s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')"
|
|
_r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
|
if [ -n "$_d" ]; then
|
|
eval "MAM_STARTUP_DIALOGS=$_d"
|
|
else
|
|
MAM_STARTUP_DIALOGS='[]'
|
|
fi
|
|
if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then
|
|
eval "MAM_STRONG_READY_TOKENS=$_s"
|
|
fi
|
|
if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then
|
|
eval "MAM_READY_TOKENS=$_r"
|
|
fi
|
|
_MAM_DIALOGS_AGENT="$agent"
|
|
return 0
|
|
}
|
|
|
|
# _mam_dialog_pick — first actionable dialog on this frame.
|
|
# stdout: "send|<idx>|<key> <key>..." or "skip|<idx>|", nothing when there is
|
|
# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts).
|
|
_mam_dialog_pick() {
|
|
MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \
|
|
MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \
|
|
python3 -c '
|
|
import os, re, sys, json
|
|
pane = os.environ.get("MAM_DLG_PANE", "")
|
|
try:
|
|
dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]")
|
|
except Exception:
|
|
dialogs = []
|
|
auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"
|
|
try:
|
|
now = int(os.environ.get("MAM_DLG_NOW") or 0)
|
|
cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5)
|
|
except ValueError:
|
|
sys.exit(1)
|
|
state = {}
|
|
for tok in (os.environ.get("MAM_DLG_STATE") or "").split():
|
|
k, _, v = tok.partition(":")
|
|
try:
|
|
state[int(k)] = int(v)
|
|
except ValueError:
|
|
pass
|
|
for idx, entry in enumerate(dialogs):
|
|
try:
|
|
pattern, keys, kind = entry[0], entry[1], entry[2]
|
|
if not re.search(pattern, pane):
|
|
continue
|
|
except Exception:
|
|
continue
|
|
last = state.get(idx)
|
|
if last is not None and 0 <= now - last < cooldown:
|
|
# rate-limited: look at the other dialogs on this screen instead of
|
|
# giving up (merge review, codex probe: a skipped trust prompt or a
|
|
# freshly answered one used to hide the notice below it)
|
|
continue
|
|
if kind == "trust" and not auto_trust:
|
|
print("skip|%d|" % idx)
|
|
sys.exit(0)
|
|
print("send|%d|%s" % (idx, " ".join(keys)))
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
'
|
|
}
|
|
|
|
# handle_startup_dialogs <sess> [timeout_sec=20] [agent=claude]
|
|
# Answer the blocking modals an agent shows before its TUI is usable. The
|
|
# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS),
|
|
# so this is agent-agnostic - issue #24: it used to be a claude-only ladder
|
|
# called only for claude, and agy/grok/codex sat on their trust prompt until
|
|
# the readiness wait timed out and onboarding was skipped.
|
|
#
|
|
# `--source visible` keeps showing an accepted dialog for a while (#24
|
|
# comment), so a signature still on screen is NOT evidence that the keys were
|
|
# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S
|
|
# (default 5s) and acceptance is judged only by the ready tokens.
|
|
#
|
|
# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per
|
|
# cooldown); `notice` dialogs are always answered.
|
|
handle_startup_dialogs() {
|
|
local sess="$1" timeout="${2:-20}" agent="${3:-claude}"
|
|
local waited=0 pane pick action rest idx keys key now
|
|
local cooldown="${MAM_DIALOG_RESEND_S:-5}"
|
|
local auto_trust="${MAM_AUTO_TRUST:-1}"
|
|
_mam_load_dialog_facts "$agent"
|
|
local dialogs="${MAM_STARTUP_DIALOGS:-[]}"
|
|
local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}"
|
|
while [ "$waited" -lt "$timeout" ]; do
|
|
pane=$(_pane_tail "$sess" 20)
|
|
now=$(date +%s)
|
|
pick=""
|
|
pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true)
|
|
if [ -n "$pick" ]; then
|
|
action="${pick%%|*}"
|
|
rest="${pick#*|}"
|
|
idx="${rest%%|*}"
|
|
keys="${rest#*|}"
|
|
if [ "$action" = "send" ]; then
|
|
for key in $keys; do
|
|
_sks_herdr send-keys -t "$sess" "$key" || true
|
|
sleep 0.3
|
|
done
|
|
else
|
|
echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2
|
|
fi
|
|
_MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now"
|
|
elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Auto-initialize the herdr translation shim wrapper on library source
|
|
_init_herdr_isolation
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue #21 (item 3): control-plane binding of a session row. The logic lives
|
|
# in lib_py/control_plane.py; these are thin shell entry points.
|
|
# mam_control_plane_json <workspace> [<row control_plane JSON>]
|
|
mam_control_plane_json() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@"
|
|
}
|
|
|
|
# mam_control_plane_prefix <json>: the leading K=V tokens for new-session, or
|
|
# nothing when no forward is needed. rc 3: a forward is needed but a path
|
|
# contains whitespace (lib.sh splits the --env flags unquoted).
|
|
mam_control_plane_prefix() {
|
|
[ -n "${1:-}" ] || return 0
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
|
|
}
|