Three `create --onboard` at once into one folder (the control root, so no control-plane prefix reaches the pane and the shim reuses an idle pane): the shim's new-session chooses a pane - an idle one in a workspace on the same cwd, a split, a fresh workspace's root pane - and then claims it with `agent start`; two shims could choose the pane one of them had just made, and the later start failed with agent_pane_busy (one create in three). mam_herdr now runs every new-session call holding one lock per server: a flock on fd 9, opened for the block and taken by lib_py.pane_lock, which also bounds the wait (MAM_PANE_CLAIM_WAIT_S, default 120). It is released with the block or with the process, however it ends. The shim heredoc is unchanged (S11). Other servers' creates are not held. Serialising the claims exposed a second race: a create's end-of-run `reconcile.sh --once` adopted the pane of another create that had not yet written its row, and that create refused its own name as already running (exit 4). Such a row - registered by the monitor, no job, for a pane created after this create began - is now replaced, not refused. Tests: real herdr - shim-level and create-level three-way races into one folder (the real herdr's pane-creating replies held back so the window is hit on every run; both fail on the base with the orchestrator's error), the different-folder test kept; pane_lock unit tests (per-server file, timeout, kill -9 holder, handover, knob); the adoption rule and its three refusals; shim contract h26 (held lock: no pane calls). The herdr box gets a ~/.zshrc: without it every first `agent start` typed into zsh's first-run menu and timed out at 30 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3175 lines
121 KiB
Bash
3175 lines
121 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.8.9"
|
|
export MAM_VERSION
|
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
|
|
|
_sanitize_herdr_agent_name() {
|
|
local n="$1"
|
|
if [ -z "$n" ]; then
|
|
printf 'agent\n'
|
|
return 0
|
|
fi
|
|
local s
|
|
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
|
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
|
if [ "${#s}" -gt 32 ]; then
|
|
local h=""
|
|
if command -v shasum >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
elif command -v sha1sum >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
elif command -v openssl >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
|
fi
|
|
if [ -n "$h" ]; then
|
|
s="${s:0:23}-$h"
|
|
else
|
|
s="${s:0:32}"
|
|
fi
|
|
fi
|
|
printf '%s\n' "$s"
|
|
}
|
|
|
|
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
|
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
|
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
|
export PATH="$PATH:$dir"
|
|
fi
|
|
done
|
|
|
|
# Central TUI dialog and readiness validation tokens (OP-6)
|
|
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
|
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
|
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
|
|
|
# Workspace-relative defaults with environment overrides (Phase Z)
|
|
HOME_DIR="${HOME_DIR:-$HOME}"
|
|
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
|
if [ -z "${LOCAL_BIN:-}" ]; then
|
|
if [ -x "$HOME_DIR/bin/herdr" ]; then
|
|
LOCAL_BIN="$HOME_DIR/bin"
|
|
elif [ -x "$HOME_DIR/.local/bin/herdr" ]; then
|
|
LOCAL_BIN="$HOME_DIR/.local/bin"
|
|
else
|
|
LOCAL_BIN="$HOME/.local/bin"
|
|
fi
|
|
fi
|
|
export HOME_DIR CLAUDE_PROJECT_DIR LOCAL_BIN
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Herdr Server Isolation support
|
|
# ---------------------------------------------------------------------------
|
|
# Paths to exclude when resolving the real herdr binary (shim/wrapper dirs).
|
|
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
|
|
_HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
|
|
|
|
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}"
|
|
|
|
_canonical_file() {
|
|
local p="$1" t d b i=0
|
|
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
|
|
t="$(readlink "$p" 2>/dev/null)" || break
|
|
case "$t" in
|
|
/*) p="$t" ;;
|
|
*) p="$(dirname "$p")/$t" ;;
|
|
esac
|
|
i=$((i + 1))
|
|
done
|
|
d="$(cd -P "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1
|
|
b="$(basename "$p")"
|
|
printf '%s/%s\n' "$d" "$b"
|
|
}
|
|
|
|
_is_shim_path() {
|
|
case "/$1/" in
|
|
*"/.mam/shim/"*|*-shim/*|*"$_HERDR_SHIM_DIR_PATTERN"*|*"$_HERDR_SKILLS_BIN_PATTERN"/*)
|
|
return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
_resolve_real_herdr_path() {
|
|
local dir cand save_ifs="$IFS" real_path=""
|
|
IFS=:
|
|
for dir in $PATH; do
|
|
[ -n "$dir" ] || continue
|
|
_is_shim_path "$dir" && continue
|
|
[ -x "$dir/herdr" ] || continue
|
|
cand="$(_canonical_file "$dir/herdr" 2>/dev/null)" || cand="$dir/herdr"
|
|
[ -n "$cand" ] || cand="$dir/herdr"
|
|
_is_shim_path "$cand" && continue
|
|
real_path="$dir/herdr"
|
|
break
|
|
done
|
|
IFS="$save_ifs"
|
|
[ -n "$real_path" ] || return 1
|
|
printf '%s\n' "$real_path"
|
|
}
|
|
|
|
has_real_herdr() {
|
|
_resolve_real_herdr_path >/dev/null 2>&1
|
|
}
|
|
|
|
_init_herdr_isolation() {
|
|
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$wrapper_dir"
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
|
|
local tmp_file="$wrapper_dir/herdr.tmp.$$.$RANDOM"
|
|
cat <<'EOF' > "$tmp_file"
|
|
#!/usr/bin/env bash
|
|
# Herdr-to-Herdr translation shim wrapper
|
|
set -euo pipefail
|
|
|
|
# This shim can run as a standalone subprocess (e.g. spawned directly by
|
|
# herdr) without inheriting the parent shell's PYTHONPATH export from
|
|
# lib.sh, which breaks the `python3 -m lib_py...` / `from lib_py...` calls
|
|
# below. Re-derive it from the shim's own on-disk location instead of
|
|
# assuming inheritance.
|
|
_SHIM_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_SKILL_DIR="$(cd "$_SHIM_DIR/../../.agents/skills" 2>/dev/null && pwd || true)"
|
|
[ -n "$_SKILL_DIR" ] && export PYTHONPATH="$_SKILL_DIR:${PYTHONPATH:-}"
|
|
|
|
# Dynamic real herdr path resolution to prevent infinite recursion
|
|
_resolve_real_herdr() {
|
|
local dir save_ifs="$IFS" real_path=""
|
|
IFS=:
|
|
for dir in $PATH; do
|
|
if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then
|
|
real_path="$dir/herdr"
|
|
break
|
|
fi
|
|
done
|
|
IFS="$save_ifs"
|
|
if [ -z "$real_path" ]; then
|
|
real_path="herdr"
|
|
fi
|
|
echo "$real_path"
|
|
}
|
|
REAL_HERDR=$(_resolve_real_herdr)
|
|
|
|
# Support parsing -L <session> before the subcommand
|
|
while [ "${1:-}" = "-L" ]; do
|
|
if [ $# -lt 2 ]; then
|
|
echo "herdr shim: -L requires an argument" >&2
|
|
exit 1
|
|
fi
|
|
export HERDR_SESSION_NAME="$2"
|
|
shift 2
|
|
done
|
|
|
|
_MAM_SESSION="${HERDR_SESSION_NAME:-default}"
|
|
if [ "$_MAM_SESSION" = "default" ]; then
|
|
_MAM_SESSION=""
|
|
elif [ "${MAM_NO_AUTOSTART:-0}" = "1" ]; then
|
|
# ISSUE-9: query-only callers (status.sh, reconcile.sh) must never start a
|
|
# daemon as a side effect of looking. A session whose server is down just
|
|
# answers server_not_running, which every caller already reads as "no live
|
|
# agents" - the same answer a freshly bootstrapped, empty server would give.
|
|
:
|
|
else
|
|
_mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c "
|
|
import sys, json, os
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
name = os.environ.get('MAM_SESS', '')
|
|
print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0')
|
|
except Exception:
|
|
print('0')
|
|
" 2>/dev/null || echo "0")
|
|
if [ "$_mam_session_running" != "1" ]; then
|
|
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
|
# blocks a normal interactive `herdr --session <name>` launch from inside
|
|
# an existing herdr pane (which is how MAM's own agents usually run).
|
|
# ISSUE-3: plain `nohup ... & disown` only ignores SIGHUP and drops bash's
|
|
# own job-table tracking - it does NOT create a new process group/session,
|
|
# so the daemon stays in the caller's PGID and dies when a process-group
|
|
# signal (SIGTERM/SIGINT from a test runner, timeout wrapper, or parent
|
|
# supervisor teardown) is broadcast to that group. setsid(1) is not
|
|
# installed by default on macOS, so spawn via Python's
|
|
# start_new_session=True (os.setsid in the child) instead - portable
|
|
# across macOS and Linux, and lib_py already depends on Python.
|
|
#
|
|
# ISSUE-9: the caller is usually an agent CLI itself (a Claude Code
|
|
# orchestrator pane), and herdr hands the daemon's environment to every
|
|
# pane it later spawns. An inherited CLAUDE_CODE_CHILD_SESSION makes each
|
|
# sub-agent claude disable transcript saving (no <uuid>.jsonl, so resume
|
|
# is impossible), and the other markers advertise the orchestrator's own
|
|
# identity to its sub-agents. Drop only per-session runtime markers - user
|
|
# configuration such as CLAUDE_PROJECT_DIR or CLAUDE_CONFIG_DIR still
|
|
# inherits. The family ids mirror what orc_onboard.sh auto-detects.
|
|
#
|
|
# ISSUE-45: the server restores its saved layout on start and would
|
|
# relaunch every saved agent with herdr's own resume command (no permission
|
|
# flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's
|
|
# herdr config with [session] resume_agents_on_restore = false, rebuilt on
|
|
# every start and checked by this herdr's `config check`
|
|
# (lib_py/herdr_config.py). Without that file the server still starts -
|
|
# with restore on, as before.
|
|
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." --herdr "$REAL_HERDR" || true)
|
|
export MAM_SERVER_CFG
|
|
if [ -z "$MAM_SERVER_CFG" ]; then
|
|
echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2
|
|
fi
|
|
_mam_server_pid=$(python3 -c '
|
|
import os, subprocess, sys
|
|
deny = ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID",
|
|
"CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_PID",
|
|
"CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET",
|
|
"CLAUDE_CODE_BRIDGE_SESSION_ID",
|
|
"ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID",
|
|
"OPENCODE_SESSION_ID", "MAM_SERVER_CFG")
|
|
env = {k: v for k, v in os.environ.items() if k not in deny}
|
|
if os.environ.get("MAM_SERVER_CFG"):
|
|
env["HERDR_CONFIG_PATH"] = os.environ["MAM_SERVER_CFG"]
|
|
p = subprocess.Popen(sys.argv[1:], start_new_session=True, env=env,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL)
|
|
print(p.pid)
|
|
' "$REAL_HERDR" --session "$_MAM_SESSION" server)
|
|
for _mam_wait_i in $(seq 1 40); do
|
|
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
|
kill -0 "$_mam_server_pid" 2>/dev/null || break
|
|
sleep 0.25
|
|
done
|
|
fi
|
|
fi
|
|
|
|
_real_herdr() {
|
|
local session="${HERDR_SESSION_NAME:-}"
|
|
if [ "$session" = "default" ]; then
|
|
session=""
|
|
fi
|
|
if [ -n "$session" ]; then
|
|
if [ "${1:-}" = "--session" ] || [ "${1:-}" = "-s" ]; then
|
|
"$REAL_HERDR" "$@"
|
|
else
|
|
"$REAL_HERDR" --session "$session" "$@"
|
|
fi
|
|
else
|
|
"$REAL_HERDR" "$@"
|
|
fi
|
|
}
|
|
|
|
_sanitize_herdr_agent_name() {
|
|
local n="$1"
|
|
if [ -z "$n" ]; then
|
|
printf 'agent\n'
|
|
return 0
|
|
fi
|
|
local s
|
|
s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g')
|
|
if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi
|
|
if [ "${#s}" -gt 32 ]; then
|
|
local h=""
|
|
if command -v shasum >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | shasum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
elif command -v sha1sum >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
|
|
elif command -v openssl >/dev/null 2>&1; then
|
|
h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
|
|
else
|
|
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000")
|
|
fi
|
|
s="${s:0:23}-${h}"
|
|
fi
|
|
printf '%s\n' "${s:0:32}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workspace scoping (ISSUE-3).
|
|
#
|
|
# Hard filter when HERDR_WORKSPACE_ID is set, else the workspace id this
|
|
# shim last persisted under $WORKSPACE_ROOT/.mam/herdr_workspace_id.
|
|
# Do not infer from cwd — that silently blocks legitimate cross-workspace
|
|
# lookups. No env and no file keeps the previous server-global lookup.
|
|
# ---------------------------------------------------------------------------
|
|
_herdr_ws_id_file() {
|
|
if [ -n "${WORKSPACE_ROOT:-}" ]; then
|
|
printf '%s\n' "$WORKSPACE_ROOT/.mam/herdr_workspace_id"
|
|
fi
|
|
}
|
|
|
|
_herdr_persist_ws_id() {
|
|
local id="$1" f
|
|
[ -n "$id" ] || return 0
|
|
f=$(_herdr_ws_id_file)
|
|
[ -n "$f" ] || return 0
|
|
mkdir -p "$(dirname "$f")" 2>/dev/null || true
|
|
printf '%s\n' "$id" > "$f" 2>/dev/null || true
|
|
}
|
|
|
|
_herdr_ws_scope() {
|
|
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
|
|
printf '%s\n' "$HERDR_WORKSPACE_ID"
|
|
return 0
|
|
fi
|
|
local f id=""
|
|
f=$(_herdr_ws_id_file)
|
|
if [ -n "$f" ] && [ -f "$f" ]; then
|
|
id=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
|
|
fi
|
|
printf '%s\n' "$id"
|
|
}
|
|
|
|
# _herdr_agent_get_scoped <name> [workspace_id]
|
|
#
|
|
# agent get with the same workspace predicate as _resolve_herdr_pane_id.
|
|
# Prints pane_id on stdout and returns 0 when the agent exists and is in
|
|
# scope. Returns 1 (prints nothing) if missing or in another workspace.
|
|
# Callers MUST wrap with `|| true`.
|
|
_herdr_agent_get_scoped() {
|
|
local name="$1"
|
|
local target_ws pid=""
|
|
# Default scope is the explicit env var only. The persisted workspace
|
|
# file is NOT used here: one MAM workspace can own several herdr
|
|
# workspaces (layout overflow / parallel create), so a single file
|
|
# must not reject a uniquely named agent. File scope applies to pane
|
|
# list / label lookup via _herdr_ws_scope.
|
|
if [ "$#" -ge 2 ]; then
|
|
target_ws="$2"
|
|
else
|
|
target_ws="${HERDR_WORKSPACE_ID:-}"
|
|
fi
|
|
[ -n "$name" ] || return 1
|
|
pid=$(_real_herdr agent get "$name" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
|
|
import sys, json, os
|
|
tws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
a = json.load(sys.stdin).get('result', {}).get('agent', {})
|
|
if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
|
|
sys.exit(1)
|
|
pid = a.get('pane_id') or ''
|
|
if pid:
|
|
print(pid)
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "")
|
|
if [ -n "$pid" ]; then
|
|
printf '%s\n' "$pid"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# _resolve_herdr_pane_id <target> [workspace_id]
|
|
#
|
|
# Resolve a session name / label to a real pane_id ("wN:pM").
|
|
# Strict order (no substring matching at any step):
|
|
# 1. herdr agent get <sanitized_name> (workspace-scoped)
|
|
# 2. herdr agent get <raw_name> (workspace-scoped)
|
|
# 3. herdr pane list [--workspace WS] matching
|
|
# 3-a. label exact
|
|
# 3-b. name exact
|
|
# 3-c. agent exact
|
|
# On success print pane_id on stdout and return 0; on failure print nothing
|
|
# and return 1. Callers MUST wrap with `|| true` (shim runs under set -e).
|
|
_resolve_herdr_pane_id() {
|
|
local target="$1"
|
|
# Fast path: caller already passed a real pane_id (e.g. "w1E:p1") — skip
|
|
# agent-get/pane-list resolution entirely.
|
|
if [[ "$target" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
|
|
printf '%s\n' "$target"
|
|
return 0
|
|
fi
|
|
local target_ws="${2:-$(_herdr_ws_scope)}"
|
|
# A caller may pass a workspace *label* (e.g. $MAM_WS_LABEL) rather than
|
|
# the raw workspace_id — panes only carry workspace_id, so translate a
|
|
# label to its id via `herdr workspace list` before filtering. Falls
|
|
# back to the original value on any failure/no-match (permissive; keeps
|
|
# prior behavior when $target_ws is already a raw id).
|
|
if [ -n "$target_ws" ]; then
|
|
local resolved_ws
|
|
resolved_ws=$(_real_herdr workspace list 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
|
|
import sys, json, os
|
|
tws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
wss = json.load(sys.stdin).get('result', {}).get('workspaces', [])
|
|
ids = {w.get('workspace_id') for w in wss}
|
|
if tws in ids:
|
|
print(tws)
|
|
sys.exit(0)
|
|
hit = next((w.get('workspace_id') for w in wss if w.get('label') == tws), '')
|
|
if hit:
|
|
print(hit)
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "$target_ws")
|
|
[ -n "$resolved_ws" ] && target_ws="$resolved_ws"
|
|
fi
|
|
local sat pid=""
|
|
sat=$(_sanitize_herdr_agent_name "$target")
|
|
|
|
local cand get_ws
|
|
if [ "$#" -ge 2 ]; then
|
|
get_ws="$target_ws"
|
|
else
|
|
get_ws="${HERDR_WORKSPACE_ID:-}"
|
|
fi
|
|
for cand in "$sat" "$target"; do
|
|
[ -n "$cand" ] || continue
|
|
pid=$(_herdr_agent_get_scoped "$cand" "$get_ws" 2>/dev/null || true)
|
|
[ -n "$pid" ] && break
|
|
done
|
|
|
|
if [ -z "$pid" ]; then
|
|
local ws_flag=()
|
|
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
|
|
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
|
|
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c "
|
|
import sys, json, os
|
|
tn = os.environ.get('TARGET_NAME', '')
|
|
tsa = os.environ.get('TARGET_SAN', '')
|
|
tws = os.environ.get('TARGET_WS', '')
|
|
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
|
|
try:
|
|
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
|
|
# Client-side filter in case an older herdr ignores --workspace.
|
|
if tws:
|
|
panes = [p for p in panes if p.get('workspace_id') == tws]
|
|
# ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback
|
|
for key in ('label', 'name'):
|
|
for p in panes:
|
|
v = p.get(key)
|
|
if v and (v == tn or v == tsa):
|
|
pid = p.get('pane_id') or ''
|
|
if pid:
|
|
print(pid)
|
|
sys.exit(0)
|
|
agent_kind = None
|
|
yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml')
|
|
if os.path.isfile(yaml_path):
|
|
try:
|
|
import yaml
|
|
with open(yaml_path) as f:
|
|
ydata = yaml.safe_load(f) or {}
|
|
for s in ydata.get('herdr_sessions', []):
|
|
if s.get('name') == tn or s.get('name') == tsa:
|
|
agent_kind = s.get('pane', {}).get('cmd') or ''
|
|
break
|
|
except Exception as e:
|
|
sys.stderr.write(f'warning: yaml load failed: {e}\n')
|
|
# BUG-1: only trust the generic agent_kind fallback when the caller's
|
|
# own target string IS the bare kind (e.g. target == 'agy'). Using a
|
|
# *derived* kind (looked up via the yaml entry matched by tn/tsa) to
|
|
# then broadly match any pane of this kind is unsound whenever another,
|
|
# unrelated same-kind session is alive in the workspace - it misattributes
|
|
# that unrelated pane as this target even though their names differ.
|
|
if agent_kind and (tn == agent_kind or tsa == agent_kind):
|
|
matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')]
|
|
if len(matching) == 1:
|
|
print(matching[0])
|
|
sys.exit(0)
|
|
elif len(matching) > 1:
|
|
# R-1: Ambiguous resolution with multiple same-kind panes — fail closed
|
|
sys.exit(1)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
# Real pane_id values look like 'w1E:p1' — the workspace segment is
|
|
# alphanumeric. A digits-only pattern would reject every live pane_id.
|
|
if [[ "$pid" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
|
|
printf '%s\n' "$pid"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
cmd="${1:-}"
|
|
if [ -z "$cmd" ]; then
|
|
echo "herdr shim: no command specified" >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
|
|
case "$cmd" in
|
|
agent)
|
|
sub="${1:-}"
|
|
shift || true
|
|
_resolve_herdr_target() {
|
|
local raw="$1"
|
|
local sat
|
|
sat=$(_sanitize_herdr_agent_name "$raw")
|
|
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
|
|
echo "$sat"
|
|
return 0
|
|
fi
|
|
if [ "$raw" != "$sat" ] && [ -n "$(_herdr_agent_get_scoped "$raw" 2>/dev/null || true)" ]; then
|
|
echo "$raw"
|
|
return 0
|
|
fi
|
|
local from_list
|
|
from_list=$(_real_herdr agent list 2>/dev/null | TARGET_NAME="$raw" TARGET_SAN="$sat" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
|
import sys, json, os
|
|
tn = os.environ.get("TARGET_NAME", "")
|
|
tsa = os.environ.get("TARGET_SAN", "")
|
|
tws = os.environ.get("TARGET_WS", "")
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
agents = d.get("result", {}).get("agents", [])
|
|
if tws:
|
|
agents = [a for a in agents if a.get("workspace_id") == tws]
|
|
for a in agents:
|
|
name = a.get("name", "")
|
|
if name and (name == tn or name == tsa):
|
|
print(a.get("pane_id") or name)
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
' 2>/dev/null || echo "")
|
|
if [ -n "$from_list" ]; then
|
|
echo "$from_list"
|
|
return 0
|
|
fi
|
|
# Explicit-env miss: do not fall through to a name that agent prompt
|
|
# would deliver into another workspace (ISSUE-3 / F-2b). File-only
|
|
# scope does not trip this — see _herdr_agent_get_scoped.
|
|
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
|
|
return 1
|
|
fi
|
|
echo "$raw"
|
|
}
|
|
|
|
if [ "$sub" = "prompt" ] && [ $# -ge 2 ]; then
|
|
t="$1"
|
|
txt="$2"
|
|
shift 2
|
|
tgt=$(_resolve_herdr_target "$t" || true)
|
|
if [ -z "$tgt" ]; then
|
|
echo "Error: no in-scope herdr agent for '$t'" >&2
|
|
exit 1
|
|
fi
|
|
_real_herdr agent prompt "$tgt" "$txt" "$@"
|
|
elif [ "$sub" = "get" ] && [ $# -ge 1 ]; then
|
|
t="$1"
|
|
shift
|
|
tgt=$(_resolve_herdr_target "$t" || true)
|
|
if [ -z "$tgt" ]; then
|
|
echo "Error: no in-scope herdr agent for '$t'" >&2
|
|
exit 1
|
|
fi
|
|
_real_herdr agent get "$tgt" "$@"
|
|
elif [ "$sub" = "read" ] && [ $# -ge 1 ]; then
|
|
t="$1"
|
|
shift
|
|
tgt=$(_resolve_herdr_target "$t" || true)
|
|
if [ -z "$tgt" ]; then
|
|
echo "Error: no in-scope herdr agent for '$t'" >&2
|
|
exit 1
|
|
fi
|
|
_real_herdr agent read "$tgt" "$@"
|
|
else
|
|
_real_herdr agent "$sub" "$@"
|
|
fi
|
|
;;
|
|
has-session)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
if [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
|
|
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
|
|
exit 0
|
|
fi
|
|
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
|
|
import sys, json, os
|
|
try:
|
|
sys.path.insert(0, os.path.abspath(".agents/skills"))
|
|
from lib_py.agents.sanitize import sanitize_herdr_agent_name
|
|
except Exception:
|
|
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
|
|
tn = os.environ.get("TARGET_NAME", "")
|
|
stn = sanitize_herdr_agent_name(tn)
|
|
tws = os.environ.get("TARGET_WS", "")
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
agents = d.get("result", {}).get("agents", [])
|
|
if tws:
|
|
agents = [a for a in agents if a.get("workspace_id") == tws]
|
|
for a in agents:
|
|
an = a.get("name", "")
|
|
if an and (an == tn or an == stn):
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
'; then
|
|
exit 0
|
|
fi
|
|
if [ -n "$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)" ]; then
|
|
exit 0
|
|
fi
|
|
exit 1
|
|
;;
|
|
new-session)
|
|
name="" ws="" run_cmd=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-s)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -s requires a value" >&2
|
|
exit 1
|
|
fi
|
|
name="$2"
|
|
shift 2
|
|
;;
|
|
-c)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -c requires a value" >&2
|
|
exit 1
|
|
fi
|
|
ws="$2"
|
|
shift 2
|
|
;;
|
|
-d) shift ;;
|
|
-x|-y) shift 2 ;;
|
|
*) run_cmd="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ -z "$run_cmd" ]; then
|
|
run_cmd="${SHELL:-/bin/bash}"
|
|
fi
|
|
|
|
parsed=$(python3 -c "
|
|
import sys, shlex
|
|
cmd = sys.argv[1]
|
|
tokens = shlex.split(cmd)
|
|
env_flags = []
|
|
binary_tokens = []
|
|
for tok in tokens:
|
|
if '=' in tok and not binary_tokens:
|
|
env_flags.append('--env ' + tok)
|
|
else:
|
|
binary_tokens.append(shlex.quote(tok))
|
|
print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens))
|
|
" "$run_cmd" 2>/dev/null || echo "")
|
|
|
|
env_flags=""
|
|
final_cmd="$run_cmd"
|
|
if [ -n "$parsed" ]; then
|
|
env_flags=$(echo "$parsed" | head -n 1 | tr '\t' ' ')
|
|
final_cmd=$(echo "$parsed" | tail -n +2)
|
|
fi
|
|
|
|
kind=""
|
|
case "$name" in
|
|
*-creator-claude|*-planner-claude|*-reviewer-claude) kind="claude" ;;
|
|
*-creator-agy|*-planner-agy|*-reviewer-agy) kind="agy" ;;
|
|
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) kind="hermes" ;;
|
|
*-creator-grok|*-planner-grok|*-reviewer-grok) kind="grok" ;;
|
|
*-creator-opencode|*-planner-opencode|*-reviewer-opencode) kind="opencode" ;;
|
|
*-creator-codex|*-planner-codex|*-reviewer-codex) kind="codex" ;;
|
|
*)
|
|
if echo "$name" | grep -qi "agy"; then kind="agy"
|
|
elif echo "$name" | grep -qi "claude"; then kind="claude"
|
|
elif echo "$name" | grep -qi "hermes"; then kind="hermes"
|
|
elif echo "$name" | grep -qi "grok"; then kind="grok"
|
|
elif echo "$name" | grep -qi "opencode"; then kind="opencode"
|
|
elif echo "$name" | grep -qi "codex"; then kind="codex"
|
|
elif echo "${final_cmd:-}" | grep -qi "agy"; then kind="agy"
|
|
elif echo "${final_cmd:-}" | grep -qi "claude"; then kind="claude"
|
|
elif echo "${final_cmd:-}" | grep -qi "hermes"; then kind="hermes"
|
|
elif echo "${final_cmd:-}" | grep -qi "grok"; then kind="grok"
|
|
elif echo "${final_cmd:-}" | grep -qi "opencode"; then kind="opencode"
|
|
elif echo "${final_cmd:-}" | grep -qi "codex"; then kind="codex"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# Strip duplicate agent binary name/path from final_cmd if present, preventing positional argument misinterpretation
|
|
final_cmd=$(python3 -c "
|
|
import sys, shlex
|
|
cmd = sys.argv[1]
|
|
kind = sys.argv[2]
|
|
try:
|
|
tokens = shlex.split(cmd)
|
|
if tokens:
|
|
first = tokens[0]
|
|
if first in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex') or any(first.endswith('/' + a) for a in ('claude', 'agy', 'hermes', 'grok', 'opencode', 'codex')) or (kind and (first == kind or first.endswith('/' + kind))):
|
|
tokens = tokens[1:]
|
|
print(' '.join(shlex.quote(t) for t in tokens))
|
|
except Exception:
|
|
print(cmd)
|
|
" "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd")
|
|
|
|
# W1: Resolve candidate workspaces by querying pane list for matching CWD
|
|
# (WorkspaceInfo has no cwd key). ISSUE-7: every workspace on this cwd is
|
|
# a candidate, in pane order; the first one with an idle pane or with
|
|
# room in its 2xK grid takes the agent, and a fresh workspace is created
|
|
# only when all of them are full. Taking just the first match found the
|
|
# full workspace every time and re-judged it overflow, so each agent past
|
|
# the fourth opened yet another workspace (4 + 1 + 1 instead of 4 + 2).
|
|
candidate_wss=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_CWD', '')
|
|
try:
|
|
target_abs = os.path.realpath(target_ws) if target_ws else ''
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
seen = []
|
|
for p in panes:
|
|
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
|
|
wid = p.get('workspace_id')
|
|
if target_abs and p_cwd == target_abs and wid and wid not in seen:
|
|
seen.append(wid)
|
|
print('\n'.join(seen))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
ws_id=""
|
|
target_pane=""
|
|
existing_ws=""
|
|
for cand_ws in $candidate_wss; do
|
|
[ -n "$cand_ws" ] || continue
|
|
# BUG-4: prefer an already-idle (agent-less) pane in the existing
|
|
# workspace over splitting a new one — avoids unbounded pane
|
|
# proliferation and 2x2 grid-layout churn when a prior agent's pane
|
|
# was left behind idle.
|
|
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
for p in panes:
|
|
if p.get('workspace_id') == target_ws and not p.get('agent') and p.get('pane_id'):
|
|
print(p.get('pane_id'))
|
|
break
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
# An idle pane is reused only when no environment has to be set: the
|
|
# pane's shell is already running, and `agent start ... -- <args>` hands
|
|
# every token after -- to the agent executable (--kind), so an
|
|
# `env K=V` prefix would reach the agent as arguments, not as its
|
|
# environment (Issue #21 R1). With env flags, fall through to
|
|
# pane split / workspace create, which take --env.
|
|
if [ -n "$idle_pane" ] && [ -z "$env_flags" ]; then
|
|
existing_ws="$cand_ws"
|
|
target_pane="$idle_pane"
|
|
break
|
|
fi
|
|
|
|
# W2a: Determine split direction policy via pane layout
|
|
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
|
|
import sys, json, os
|
|
target_ws = os.environ.get('TARGET_WS', '')
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
panes = d.get('result', {}).get('panes', [])
|
|
for p in panes:
|
|
if p.get('workspace_id') == target_ws and p.get('pane_id'):
|
|
print(p.get('pane_id'))
|
|
break
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
|
|
split_dir=""
|
|
if [ -n "$sample_pane" ]; then
|
|
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
|
|
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-15}" --min-rows "${MAM_MIN_PANE_ROWS:-0}" --max-cols "${MAM_MAX_PANE_COLS:-2}" --max-rows "${MAM_MAX_PANE_ROWS:-2}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
|
split_dir="${split_dir:-right}"
|
|
sample_pane="${split_target:-$sample_pane}"
|
|
fi
|
|
|
|
if [ "$split_dir" = "overflow" ]; then
|
|
# W2b: this workspace's grid is full — try the next one on the same cwd
|
|
# (ISSUE-7); a fresh workspace is created below only if none has room.
|
|
continue
|
|
fi
|
|
existing_ws="$cand_ws"
|
|
[ "$split_dir" = "down" ] || split_dir="right"
|
|
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
|
|
target_pane=$(echo "$split_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
break
|
|
done
|
|
|
|
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
|
|
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" ${MAM_WS_LABEL:+--label "$MAM_WS_LABEL"} $env_flags --no-focus 2>/dev/null || echo "")
|
|
target_pane=$(echo "$ws_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
w_obj = res.get('workspace', {})
|
|
print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
ws_id=$(echo "$ws_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
res = d.get('result', {})
|
|
print(res.get('workspace', {}).get('workspace_id') or '')
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null || echo "")
|
|
else
|
|
if [ -n "${MAM_WS_LABEL:-}" ]; then
|
|
_real_herdr workspace rename "$existing_ws" "$MAM_WS_LABEL" >/dev/null 2>&1 || true
|
|
fi
|
|
ws_id="$existing_ws"
|
|
fi
|
|
if [ -n "${ws_id:-}" ]; then
|
|
export HERDR_WORKSPACE_ID="$ws_id"
|
|
_herdr_persist_ws_id "$ws_id"
|
|
fi
|
|
|
|
if [ -z "$target_pane" ]; then
|
|
echo "Error: target_pane allocation failed for $name" >&2
|
|
exit 1
|
|
fi
|
|
if [ -z "$kind" ]; then
|
|
echo "Error: unable to determine agent kind for session $name" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors
|
|
res=""
|
|
success=0
|
|
backoffs=(0.5 1 2)
|
|
agent_name=$(_sanitize_herdr_agent_name "$name")
|
|
for i in $(seq 0 2); do
|
|
if [ -n "$final_cmd" ]; then
|
|
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true)
|
|
else
|
|
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
|
|
fi
|
|
if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
|
|
break
|
|
fi
|
|
# Fatal CLI errors abort immediately. agent_not_ready is the
|
|
# documented "process is up, blocked on a dialog" status — continue
|
|
# to wait_for_tui_ready. Do NOT treat "timed out waiting for agent
|
|
# startup" as success: herdr returns that for a dead process too.
|
|
if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
|
|
success=1
|
|
break
|
|
fi
|
|
if [ "$i" -lt 2 ]; then
|
|
sleep "${backoffs[$i]}"
|
|
fi
|
|
done
|
|
|
|
if [ "$success" -ne 1 ]; then
|
|
# A "timed out waiting for agent startup" result is ambiguous — herdr
|
|
# returns the identical text whether the process died or the TUI
|
|
# render was just slow (see the comment above the retry loop). Rather
|
|
# than trust that text either way, re-query herdr directly: if the
|
|
# agent is actually registered and alive, treat this as success; if
|
|
# not, fall back to sending the launch command straight into the
|
|
# pane before giving up.
|
|
if echo "$res" | grep -qiE "timed out waiting for agent startup"; then
|
|
if [ -n "$(_herdr_agent_get_scoped "$agent_name" 2>/dev/null || true)" ]; then
|
|
success=1
|
|
else
|
|
if [ -n "$final_cmd" ]; then
|
|
_real_herdr pane send-text "$target_pane" "$final_cmd" >/dev/null 2>&1 || true
|
|
_real_herdr pane send-keys "$target_pane" Enter >/dev/null 2>&1 || true
|
|
fi
|
|
sleep 1
|
|
if [ -n "$(_herdr_agent_get_scoped "$agent_name" 2>/dev/null || true)" ]; then
|
|
success=1
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ "$success" -ne 1 ]; then
|
|
echo "$res" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
kill-session)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
# NOTE: herdr's `session` verb operates on whole server instances (see
|
|
# `herdr session list`), not individual agent panes — `session stop/delete
|
|
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
|
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
|
fi
|
|
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 \
|
|
|| _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
|
|
;;
|
|
list-panes)
|
|
sess="" format=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
-F)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -F requires a value" >&2
|
|
exit 1
|
|
fi
|
|
format="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
# NOTE: `herdr agent get <target>` returns {"result": {"agent": {...}}} —
|
|
# there is no top-level "pane" key, and no "pid" field at all (only
|
|
# "pane_id", herdr's own wN:pN identifier). The real OS pid requires a
|
|
# second call to `pane process-info --pane <pane_id>`.
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
info=$(_real_herdr agent get "$agent_target" 2>/dev/null || _real_herdr agent get "$sess" 2>/dev/null || true)
|
|
pane_id="" cwd="" cmd=""
|
|
if [ -n "$info" ]; then
|
|
parsed=$(python3 -c "
|
|
import sys, json
|
|
try:
|
|
raw = sys.stdin.read()
|
|
if '{' in raw:
|
|
raw = raw[raw.index('{'):]
|
|
a = json.loads(raw).get('result', {}).get('agent', {})
|
|
except Exception:
|
|
a = {}
|
|
print(a.get('pane_id', ''))
|
|
print(a.get('cwd', ''))
|
|
print(a.get('agent', ''))
|
|
" <<< "$info")
|
|
pane_id=$(sed -n '1p' <<< "$parsed")
|
|
cwd=$(sed -n '2p' <<< "$parsed")
|
|
cmd=$(sed -n '3p' <<< "$parsed")
|
|
fi
|
|
pid=""
|
|
if [ -n "$pane_id" ]; then
|
|
case "$format" in
|
|
*pane_pid*)
|
|
pid=$(_real_herdr pane process-info --pane "$pane_id" 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
pi = json.loads(sys.stdin.read()).get('result', {}).get('process_info', {})
|
|
fg = pi.get('foreground_processes') or []
|
|
print(fg[0]['pid'] if fg else pi.get('shell_pid', ''))
|
|
except Exception:
|
|
pass
|
|
")
|
|
;;
|
|
esac
|
|
fi
|
|
case "$format" in
|
|
*'#{pane_pid}'*'#{pane_current_path}'*|*#{pane_pid}*#{pane_current_path}*)
|
|
printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd"
|
|
;;
|
|
*'#{pane_pid}'*|*#{pane_pid}*)
|
|
printf '%s\n' "$pid"
|
|
;;
|
|
*'#{pane_current_path}'*|*#{pane_current_path}*)
|
|
printf '%s\n' "$cwd"
|
|
;;
|
|
*'#{pane_current_command}'*|*#{pane_current_command}*)
|
|
printf '%s\n' "$cmd"
|
|
;;
|
|
*)
|
|
printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd"
|
|
;;
|
|
esac
|
|
;;
|
|
capture-pane)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|
|
|| _real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
|
|
|| _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
|
else
|
|
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
|
|
|| _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
|
fi
|
|
;;
|
|
send-keys)
|
|
sess="" key=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) key="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
|
|
key="Enter"
|
|
fi
|
|
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
|
# resolve it via `agent get` first (agent-level commands accept names).
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
|
else
|
|
_real_herdr pane send-keys "$agent_target" "$key" >/dev/null 2>&1 || _real_herdr pane send-keys "$sess" "$key" >/dev/null 2>&1 || true
|
|
fi
|
|
;;
|
|
set-buffer)
|
|
buf="tmp_buffer"
|
|
text=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-b)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -b requires a value" >&2
|
|
exit 1
|
|
fi
|
|
buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
|
|
if [ -z "$buf" ]; then
|
|
echo "Error: -b name contains no usable characters" >&2
|
|
exit 1
|
|
fi
|
|
buf="buf_$buf"
|
|
shift 2
|
|
;;
|
|
*) text="$1"; shift ;;
|
|
esac
|
|
done
|
|
# A-3 GC: unique buffer names mean an abandoned buffer (SIGINT/SIGTERM/
|
|
# timeout between set-buffer and delete-buffer) is never overwritten, so
|
|
# it would leak forever. Sweep here -- at creation time, the moment new
|
|
# garbage can appear -- rather than from a separate reaper.
|
|
#
|
|
# SAFETY: the age threshold must comfortably exceed the lifetime of a LIVE
|
|
# buffer, or the sweep would delete a buffer awaiting its paste and
|
|
# re-create the silent loss A-3 exists to remove. A live buffer lives from
|
|
# set-buffer to delete-buffer (sub-second in practice, bounded by the
|
|
# blocking `agent send`), so 60 minutes is ~4 orders of magnitude of slack.
|
|
# Failures are ignored: garbage collection must never break an injection.
|
|
#
|
|
# The '.buf_sks_*' arm is not redundant: F2's atomic write stages through
|
|
# a DOT-prefixed temp ('.buf_sks_....tmp'), which 'buf_sks_*' cannot match.
|
|
# Without it a temp orphaned by SIGKILL between write and rename would leak
|
|
# forever, exactly like the buffers this sweep exists to reclaim.
|
|
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
|
|
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
|
|
mkdir -p "$buffer_dir" 2>/dev/null || true
|
|
find "$buffer_dir" \( -name 'buf_sks_*' -o -name '.buf_sks_*' \) \
|
|
-mmin +${MAM_BUFFER_GC_MINUTES:-60} -delete 2>/dev/null || true
|
|
_tmp="$buffer_dir/.$buf.$$.tmp"
|
|
if ! { echo -n "$text" > "$_tmp" && mv -f "$_tmp" "$buffer_dir/$buf"; }; then
|
|
rm -f "$_tmp"
|
|
echo "Error: failed to write buffer $buf" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
paste-buffer)
|
|
buf="tmp_buffer"
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-b)
|
|
buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
|
|
if [ -z "$buf" ]; then
|
|
echo "Error: -b name contains no usable characters" >&2
|
|
exit 1
|
|
fi
|
|
buf="buf_$buf"
|
|
shift 2
|
|
;;
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
|
|
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
|
|
if [ ! -f "$buffer_dir/$buf" ]; then
|
|
echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
|
|
exit 1
|
|
fi
|
|
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
|
|
if [ -z "$pane_id" ]; then
|
|
# herdr has no `agent send` subcommand. Returning success here would let
|
|
# send_keys_safe submit an empty prompt.
|
|
echo "Error: paste-buffer could not resolve a pane for '$sess'" >&2
|
|
exit 1
|
|
fi
|
|
# Insert only. Submission is owned exclusively by send_keys_safe (ISSUE-1).
|
|
# A combined insert-and-submit command would double-submit.
|
|
if ! _real_herdr pane send-text "$pane_id" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1; then
|
|
echo "Error: pane send-text failed for '$sess' ($pane_id)" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
delete-buffer)
|
|
buf="tmp_buffer"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-b)
|
|
buf="$(echo "$2" | tr -cd 'A-Za-z0-9_.-')"
|
|
if [ -z "$buf" ]; then
|
|
echo "Error: -b name contains no usable characters" >&2
|
|
exit 1
|
|
fi
|
|
buf="buf_$buf"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
|
|
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
|
|
rm -f "$buffer_dir/$buf"
|
|
;;
|
|
ls)
|
|
format=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-F)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -F requires a value" >&2
|
|
exit 1
|
|
fi
|
|
format="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
# herdr has no session-creation timestamp: `agent list`, `agent get`,
|
|
# `pane get` and `api snapshot` all lack one. Derive it from the OS as the
|
|
# start time of the pane's ROOT process (`shell_pid`).
|
|
#
|
|
# It must NOT come from `foreground_processes[0]`: under MAM that slot
|
|
# holds the `caffeinate -i -t 300` keep-awake wrapper, which respawns every
|
|
# five minutes. Its start time creeps forward, so a session idle for longer
|
|
# than one caffeinate cycle would look "created after" its own transcript
|
|
# and be discarded by the stale-transcript guard in verify_session_uuid.
|
|
#
|
|
# This runs as ONE python process that re-implements `_real_herdr` and
|
|
# batches a single `ps` over every pid, rather than forking python and ps
|
|
# once per agent inside a shell loop: reconcile calls this on a 15s
|
|
# heartbeat, so per-agent forking shows up as a standing cost.
|
|
#
|
|
# TZ=UTC pins both `ps` (which prints lstart in the caller's zone) and
|
|
# `mktime` (which reads it back in the caller's zone) to the same zone.
|
|
# They already agree at any single TZ, so this is not about skew between
|
|
# them -- it removes the once-a-year DST ambiguity in local-time mktime.
|
|
# LC_ALL=C pins lstart's field names, which are locale-dependent.
|
|
_sess="${HERDR_SESSION_NAME:-}"
|
|
if [ "$_sess" = "default" ]; then
|
|
_sess=""
|
|
fi
|
|
# ISSUE-9: under MAM_NO_AUTOSTART the session's server may simply be down.
|
|
# Say so in the wording reconcile.sh already treats as "empty" ("no server
|
|
# running", tmux's phrasing) instead of an opaque parse failure, which
|
|
# would flip herdr_confirmed=False and freeze drift detection for every
|
|
# server on the next heartbeat.
|
|
# Only a FAILED call whose structured error envelope carries
|
|
# error.code == "server_not_running" means "down". A successful list can
|
|
# legitimately contain that text (an agent named server_not_running-codex),
|
|
# and reading it as "down" made reconcile terminate every live row on that
|
|
# server. Any other failed call is "unknown": it exits nonzero below
|
|
# without its output ever reaching the list parser, so an error document on
|
|
# stdout cannot pass for an empty list either.
|
|
_ls_err=$(mktemp 2>/dev/null || echo "/tmp/mam_ls_err_$$")
|
|
_ls_rc=0
|
|
_ls_raw="$(_real_herdr agent list 2>"$_ls_err")" || _ls_rc=$?
|
|
_ls_down=0
|
|
if [ "$_ls_rc" -ne 0 ]; then
|
|
_ls_down=$(printf '%s' "$_ls_raw" | MAM_LS_ERR_FILE="$_ls_err" python3 -c '
|
|
import json, os, sys
|
|
out = sys.stdin.read()
|
|
try:
|
|
with open(os.environ["MAM_LS_ERR_FILE"]) as f:
|
|
err_text = f.read()
|
|
except Exception:
|
|
err_text = ""
|
|
down = 0
|
|
for text in [out, err_text] + out.splitlines() + err_text.splitlines():
|
|
try:
|
|
doc = json.loads(text)
|
|
except Exception:
|
|
continue
|
|
err = doc.get("error") if isinstance(doc, dict) else None
|
|
if isinstance(err, dict) and err.get("code") == "server_not_running":
|
|
down = 1
|
|
break
|
|
print(down)
|
|
' 2>/dev/null || echo 0)
|
|
fi
|
|
rm -f "$_ls_err" 2>/dev/null || true
|
|
if [ "$_ls_down" = "1" ]; then
|
|
echo "no server running (herdr session ${_sess:-default})" >&2
|
|
exit 1
|
|
fi
|
|
if [ "$_ls_rc" -ne 0 ]; then
|
|
# Unknown, never empty: keep the nonzero status and word the diagnostic
|
|
# so it matches none of reconcile.sh's empty-server phrases ("no server
|
|
# running", "no sessions", "failed to connect").
|
|
echo "herdr agent list returned rc=$_ls_rc (herdr session ${_sess:-default}); state unknown" >&2
|
|
exit 1
|
|
fi
|
|
printf '%s\n' "$_ls_raw" | \
|
|
MAM_LS_FORMAT="$format" MAM_REAL_HERDR="$REAL_HERDR" MAM_LS_SESSION="$_sess" \
|
|
TZ=UTC LC_ALL=C python3 -c '
|
|
import json, os, subprocess, sys, time
|
|
|
|
fmt = os.environ.get("MAM_LS_FORMAT", "")
|
|
real = os.environ.get("MAM_REAL_HERDR") or "herdr"
|
|
sess = os.environ.get("MAM_LS_SESSION") or ""
|
|
base = [real] + (["--session", sess] if sess else [])
|
|
|
|
raw = sys.stdin.read()
|
|
try:
|
|
agents = json.loads(raw).get("result", {}).get("agents", []) or []
|
|
except Exception:
|
|
# Unparseable means "we do not know", not "there are no sessions".
|
|
# Exiting nonzero lets reconcile fall into its herdr_confirmed=False path
|
|
# instead of reading empty stdout as a confirmed zero and terminating
|
|
# every live row it has on file.
|
|
sys.exit(1)
|
|
|
|
rows = []
|
|
for a in agents:
|
|
name = a.get("name") or a.get("agent") or "unknown"
|
|
pane = a.get("pane_id") or ""
|
|
pid = None
|
|
if pane:
|
|
try:
|
|
out = subprocess.run(base + ["pane", "process-info", "--pane", pane],
|
|
capture_output=True, text=True, timeout=10).stdout
|
|
pi = json.loads(out).get("result", {}).get("process_info", {})
|
|
p = pi.get("shell_pid") or pi.get("foreground_process_group_id")
|
|
if isinstance(p, int) and p > 0:
|
|
pid = p
|
|
except Exception:
|
|
pass
|
|
rows.append((name, pid))
|
|
|
|
starts = {}
|
|
pids = sorted({p for _, p in rows if p})
|
|
if pids:
|
|
try:
|
|
out = subprocess.run(["ps", "-o", "pid=,lstart=", "-p", ",".join(str(p) for p in pids)],
|
|
capture_output=True, text=True, timeout=10).stdout
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
head, _, rest = line.partition(" ")
|
|
try:
|
|
starts[int(head)] = int(time.mktime(time.strptime(rest.strip(), "%a %b %d %H:%M:%S %Y")))
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Degrade to now, never to 0 or a sentinel. The consumer guard is
|
|
# if epoch and mtime(transcript) < epoch: reject
|
|
# so an over-estimate only makes it stricter, while an under-estimate
|
|
# (0 is falsy; 999999 is 1970-01-12 and below every real mtime) switches
|
|
# the guard off outright -- which is the B-4 defect.
|
|
now = int(time.time())
|
|
for name, pid in rows:
|
|
if fmt == "#{session_name}":
|
|
print(name)
|
|
else:
|
|
print(name + "|" + str(starts.get(pid) or now))
|
|
'
|
|
;;
|
|
*)
|
|
_real_herdr "$cmd" "$@"
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$tmp_file" 2>/dev/null || true
|
|
mv -f "$tmp_file" "$wrapper_dir/herdr" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null || true
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
}
|
|
|
|
# _herdr_call_is_cross_server
|
|
#
|
|
# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID /
|
|
# HERDR_TAB_ID, and those ids only mean something on the server that issued
|
|
# them. When a MAM script runs inside one server's pane (the orchestrator's)
|
|
# but targets another (the isolated MAM server named by HERDR_SESSION_NAME),
|
|
# the inherited ids reach the scope filters (_herdr_ws_scope,
|
|
# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and
|
|
# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a
|
|
# live session as absent: has-session, kill-session, agent prompt and the
|
|
# delegate-job preflight all answer "no such session".
|
|
#
|
|
# _herdr_caller_server — the name of the server whose pane this process runs
|
|
# in; empty when it is not in a pane at all.
|
|
#
|
|
# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a
|
|
# pane of the *default* server (the orchestrator's usual home), which is
|
|
# exactly the pane the #23 leak comes from - the live trial of this branch
|
|
# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is
|
|
# exported to every pane, and its layout names the server:
|
|
#
|
|
# $HOME/.config/herdr/herdr.sock -> default
|
|
# $HOME/.config/herdr/sessions/<name>/herdr.sock -> <name>
|
|
#
|
|
# (the same convention lib.sh's shim already relies on to find a named
|
|
# server's socket). Matched by shape, not by $HOME, so a redirected HOME does
|
|
# not break it. An unrecognised layout yields "" - treated as "not in a pane",
|
|
# which keeps today's behaviour rather than guessing.
|
|
_herdr_caller_server() {
|
|
if [ -n "${HERDR_SESSION:-}" ]; then
|
|
printf '%s\n' "$HERDR_SESSION"
|
|
return 0
|
|
fi
|
|
local sock="${HERDR_SOCKET_PATH:-}"
|
|
case "$sock" in
|
|
*/.config/herdr/herdr.sock)
|
|
printf 'default\n'
|
|
;;
|
|
*/.config/herdr/sessions/*/herdr.sock)
|
|
local d="${sock%/herdr.sock}"
|
|
printf '%s\n' "${d##*/}"
|
|
;;
|
|
*)
|
|
printf '\n'
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# True only for a call aimed at a *named* server other than the caller's own:
|
|
#
|
|
# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel
|
|
# for "pass no session flag", so herdr answers on the ambient server, which
|
|
# for a caller inside a pane is that same caller's server - not a crossing;
|
|
# * no caller server (_herdr_caller_server empty) means the caller is not in
|
|
# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope,
|
|
# not a leak (H-18/H-21/H-23);
|
|
# * caller == target is the same server.
|
|
#
|
|
# A caller in the default server's pane targeting a named server IS a crossing
|
|
# (the reported #23 scenario): `default` is a real server name on the caller's
|
|
# side, only as a *target* is it the "ambient" sentinel.
|
|
_herdr_call_is_cross_server() {
|
|
local caller target="${HERDR_SESSION_NAME:-}"
|
|
case "$target" in
|
|
""|default) return 1 ;;
|
|
esac
|
|
caller="$(_herdr_caller_server)"
|
|
if [ -n "$caller" ] && [ "$caller" != "$target" ]; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# _herdr_scope_is_pane_inherited <herdr argv...>
|
|
#
|
|
# ISSUE-34: true for a call that names an agent (agent / has-session /
|
|
# kill-session) from inside a pane whose own workspace is the exported
|
|
# HERDR_WORKSPACE_ID. A pane exports it together with HERDR_PANE_ID
|
|
# "<workspace>:<pane>"; when the two agree the id says where the caller lives,
|
|
# not where to look - a worktree creator in its own workspace must reach a
|
|
# reviewer in mam-agent on the same server. Live agent names are unique per
|
|
# server (herdr --skill: "Names must ... be unique among live agents", "IDs and
|
|
# live agent names are scoped to one server"), so a name lookup without that
|
|
# filter cannot pick a same-named agent of another workspace: the ISSUE-3 /
|
|
# F-2b hazard exists only across servers (_herdr_call_is_cross_server). An id
|
|
# that disagrees with the pane, or one set outside any pane, is still a
|
|
# deliberate scope (H-18/H-21/H-23); new-session and pane calls keep it, so a
|
|
# pane caller still creates panes in its own workspace.
|
|
_herdr_scope_is_pane_inherited() {
|
|
while [ "${1:-}" = "-L" ]; do shift 2 || return 1; done
|
|
case "${1:-}" in
|
|
agent|has-session|kill-session) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
local pane="${HERDR_PANE_ID:-}"
|
|
[ -n "${HERDR_WORKSPACE_ID:-}" ] && [ -n "$pane" ] && [ "${pane%%:*}" = "$HERDR_WORKSPACE_ID" ]
|
|
}
|
|
|
|
# Issue #50 follow-up: one pane claim at a time per herdr server.
|
|
#
|
|
# The shim's new-session chooses a pane - an idle one in a workspace on the
|
|
# same cwd (BUG-4), a split of one, or the root pane of a fresh workspace - and
|
|
# then claims it with `agent start`. Two shims on one server (several creates
|
|
# at once into one folder) could choose the same idle pane in between, and the
|
|
# later `agent start` failed with agent_pane_busy. mam_herdr runs the whole
|
|
# new-session call holding one lock per server: a flock on fd 9, opened for
|
|
# the block below and taken by lib_py.pane_lock (which also bounds the wait,
|
|
# MAM_PANE_CLAIM_WAIT_S). It ends with the block or with this process, however
|
|
# that ends, so a dead holder leaves nothing behind; 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
|
|
# targets ("" for the default one); rc 1 for any other call.
|
|
_mam_pane_claim_target() {
|
|
local sess="${HERDR_SESSION_NAME:-}"
|
|
while [ "${1:-}" = "-L" ]; do sess="${2:-}"; shift 2 || return 1; done
|
|
[ "${1:-}" = "new-session" ] || return 1
|
|
[ "$sess" = "default" ] && sess=""
|
|
printf '%s\n' "$sess"
|
|
}
|
|
|
|
# _mam_with_pane_claim <server> <cmd...>: run <cmd> holding <server>'s pane lock.
|
|
_mam_with_pane_claim() {
|
|
local sess="$1" f=""
|
|
shift
|
|
f=$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock path --session "$sess") || f=""
|
|
if [ -z "$f" ]; then
|
|
echo "ERROR: no pane lock path for herdr session ${sess:-default} (lib_py.pane_lock); not placing this session" >&2
|
|
return 1
|
|
fi
|
|
mkdir -p "$(dirname "$f")"
|
|
{
|
|
case "$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" python3 -m lib_py.pane_lock acquire --fd 9)" in
|
|
locked) ;;
|
|
timeout)
|
|
echo "ERROR: another pane claim on herdr session ${sess:-default} did not end within MAM_PANE_CLAIM_WAIT_S; not placing this session" >&2
|
|
return 1 ;;
|
|
*)
|
|
echo "ERROR: the pane lock of herdr session ${sess:-default} could not be decided (lib_py.pane_lock); not placing this session" >&2
|
|
return 1 ;;
|
|
esac
|
|
"$@"
|
|
} 9>>"$f"
|
|
}
|
|
|
|
mam_herdr() {
|
|
_init_herdr_isolation
|
|
# Built as an argv array and decided inside `if`: this runs under
|
|
# `set -euo pipefail` in most callers, where a bare failing test would abort
|
|
# the whole script instead of this one call.
|
|
local -a _scope_strip=()
|
|
if _herdr_call_is_cross_server; then
|
|
_scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID)
|
|
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
|
echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2
|
|
fi
|
|
elif _herdr_scope_is_pane_inherited "$@"; then
|
|
_scope_strip=(env -u HERDR_WORKSPACE_ID)
|
|
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
|
echo "mam_herdr: '$1' looks up by name; the caller pane's own workspace ${HERDR_WORKSPACE_ID} is not a scope" >&2
|
|
fi
|
|
fi
|
|
local _claim_sess=""
|
|
if _claim_sess=$(_mam_pane_claim_target "$@"); then
|
|
_mam_with_pane_claim "$_claim_sess" ${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
|
return
|
|
fi
|
|
${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
|
}
|
|
|
|
_herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_herdr_server <session_name>
|
|
#
|
|
# Query agent-sessions.yaml to find the herdr_session associated with a session.
|
|
# Fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME or workspace slug or 'default' if not registered.
|
|
# Prints the resolved server name on stdout.
|
|
# ---------------------------------------------------------------------------
|
|
# NOTE: Retained as small inline PYEOF block (39 lines) per Plan Rev.2 (Job 98393a97).
|
|
# Small blocks have minimal overhead and high local cohesion; extraction into lib_py is reserved for large blocks (>200 lines).
|
|
load_state_json() {
|
|
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, sqlite3, json, yaml
|
|
yaml_path = os.environ['YAML_PATH']
|
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
d = {}
|
|
db_sessions = []
|
|
try:
|
|
if os.path.exists(db_path):
|
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
conn.execute('PRAGMA busy_timeout = 60000')
|
|
try:
|
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
if row:
|
|
d = json.loads(row[0])
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
try:
|
|
cursor = conn.execute('SELECT data FROM sessions')
|
|
for r in cursor.fetchall():
|
|
db_sessions.append(json.loads(r[0]))
|
|
d['herdr_sessions'] = db_sessions
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.close()
|
|
elif os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
pass
|
|
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
|
def clean_surrogates(obj):
|
|
if isinstance(obj, str):
|
|
return obj.encode('utf-8', errors='replace').decode('utf-8')
|
|
elif isinstance(obj, dict):
|
|
return {k: clean_surrogates(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [clean_surrogates(x) for x in obj]
|
|
return obj
|
|
d = clean_surrogates(d)
|
|
print(json.dumps(d, ensure_ascii=False))
|
|
PYEOF
|
|
}
|
|
|
|
# resolve_agent_type_from_registry <session_name>
|
|
#
|
|
# 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는
|
|
# lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다:
|
|
# ① row['agent'] 명시 필드
|
|
# ② 세션명 접미사 (*-{creator,planner,reviewer}-<agent> 및 *-<agent>)
|
|
# ③ pane.cmd (정확히 일치하거나 .../<agent> 바이너리 경로)
|
|
# 성공하면 에이전트명을 stdout 에 출력하고 0 을, 셋 다 실패하면 아무것도
|
|
# 출력하지 않고 1 을 반환한다. 오류 메시지는 호출자가 소유한다 — 각 스크립트가
|
|
# 문서화한 종료 코드를 그대로 유지하기 위해서다.
|
|
#
|
|
# NOTE: agent_of_row 의 match_cmd=True 는 "비-입양 조회" 계약이다. reconcile.sh
|
|
# 입양 루프는 이 헬퍼를 쓰면 안 된다 (3aee63cf §1.2 실측 반증).
|
|
resolve_agent_type_from_registry() {
|
|
local name="$1"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$name" python3 -c "
|
|
import os, json, sys
|
|
from lib_py.agents.registry import agent_of_row
|
|
name = os.environ['SESSION_NAME']
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
|
|
resolved = agent_of_row(row, session_name=name)
|
|
if not resolved:
|
|
sys.exit(1)
|
|
print(resolved)
|
|
"
|
|
}
|
|
|
|
# resolve_herdr_session <session_name> [workspace]
|
|
#
|
|
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
|
# not a workspace label. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
|
|
# generated wrapper) — a workspace label match provides no actual isolation
|
|
# since agent/pane commands are server-global regardless of workspace.
|
|
|
|
resolve_herdr_session() {
|
|
local session_name="$1"
|
|
local workspace="${2:-}"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
|
|
import sys, os, json
|
|
name = os.environ['SESSION_NAME']
|
|
ws = os.environ.get('TARGET_WS', '').strip()
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == name:
|
|
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
|
|
val = s.get('herdr_session') or s.get('herdr_server')
|
|
if val and val != 'default':
|
|
print(val)
|
|
sys.exit(0)
|
|
fallback = ''
|
|
if ws:
|
|
abs_ws = os.path.abspath(ws)
|
|
parent = os.path.basename(os.path.dirname(abs_ws)) or 'workspace'
|
|
work = os.path.basename(abs_ws) or 'root'
|
|
if parent in ('/', '.'): parent = 'workspace'
|
|
if work in ('/', '.'): work = 'root'
|
|
slug = f'{parent}-{work}'.lower().replace('_', '-')
|
|
import re
|
|
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lstrip('-')
|
|
fallback = f'mam-{slug}' if slug else 'mam-ws'
|
|
else:
|
|
fallback = os.environ.get('HERDR_SESSION_NAME', '') or os.environ.get('HERDR_SERVER_NAME', '')
|
|
if not fallback or fallback == 'default':
|
|
fallback = 'default'
|
|
print('WARN: resolve_herdr_session called without workspace parameter for unregistered session; falling back to default', file=sys.stderr)
|
|
print(fallback or 'default')
|
|
"
|
|
}
|
|
|
|
# resolve_herdr_workspace <session_name> [workspace]
|
|
#
|
|
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
|
|
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
|
|
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
|
|
#
|
|
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
|
|
# ① row['herdr_workspace'] — 명시 기록
|
|
# ② row['pane']['cwd'] 의 슬러그 — 등록된 세션의 실제 작업 디렉터리
|
|
# ③ 인자 workspace 의 슬러그 — 미등록 세션 전용 폴백
|
|
# ④ 빈 문자열
|
|
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
|
|
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
|
|
# pane.cwd 를 물려받기 때문 (D5).
|
|
resolve_herdr_workspace() {
|
|
local session_name="$1"
|
|
local workspace="${2:-}"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
|
|
import sys, os, json, re
|
|
name = os.environ['SESSION_NAME']
|
|
ws = os.environ.get('TARGET_WS', '').strip()
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
|
|
def slug(path):
|
|
if not path:
|
|
return ''
|
|
a = os.path.abspath(path)
|
|
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
|
|
work = os.path.basename(a) or 'root'
|
|
if parent in ('/', '.'): parent = 'workspace'
|
|
if work in ('/', '.'): work = 'root'
|
|
s = f'{parent}-{work}'.lower().replace('_', '-')
|
|
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
|
|
|
|
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)
|
|
|
|
# ① 명시 기록
|
|
if row and row.get('herdr_workspace'):
|
|
print(row['herdr_workspace']); sys.exit(0)
|
|
|
|
# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
|
|
if row:
|
|
derived = slug((row.get('pane') or {}).get('cwd', ''))
|
|
if derived:
|
|
print(derived); sys.exit(0)
|
|
|
|
# ③ 미등록(또는 cwd 부재) 세션 폴백
|
|
if ws:
|
|
derived = slug(ws)
|
|
if derived:
|
|
print(derived); sys.exit(0)
|
|
|
|
print('')
|
|
"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_workspace_slug <workspace>
|
|
# ---------------------------------------------------------------------------
|
|
derive_workspace_slug() {
|
|
local workspace="${1:-$PWD}"
|
|
local abs parent work slug
|
|
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
|
parent="$(basename "$(dirname "$abs")" 2>/dev/null || echo "")"
|
|
work="$(basename "$abs" 2>/dev/null || echo "root")"
|
|
if [ -z "$parent" ] || [ "$parent" = "/" ] || [ "$parent" = "." ]; then
|
|
parent="workspace"
|
|
fi
|
|
if [ -z "$work" ] || [ "$work" = "/" ] || [ "$work" = "." ]; then
|
|
work="root"
|
|
fi
|
|
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
|
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
|
|
slug="$(printf '%s' "$slug" | sed 's/^-*//')"
|
|
if [ -z "$slug" ]; then
|
|
slug="ws"
|
|
fi
|
|
printf 'mam-%s' "$slug"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_gen_uuid
|
|
# ---------------------------------------------------------------------------
|
|
mam_gen_uuid() {
|
|
if command -v uuidgen >/dev/null 2>&1; then
|
|
uuidgen | tr 'A-Z' 'a-z'
|
|
else
|
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_abs_workspace <path>
|
|
# ---------------------------------------------------------------------------
|
|
mam_abs_workspace() {
|
|
local p="${1:-}"
|
|
( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mam_workspace_key <workspace>
|
|
# ---------------------------------------------------------------------------
|
|
mam_workspace_key() {
|
|
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_session_name <workspace> <agent> [role]
|
|
# ---------------------------------------------------------------------------
|
|
derive_session_name() {
|
|
local workspace="${1:-$PWD}" agent="${2:-}" role="${3:-creator}"
|
|
role=$(echo "$role" | tr '[:upper:]' '[:lower:]')
|
|
local base_slug
|
|
base_slug="$(derive_workspace_slug "$workspace")"
|
|
local slug="${base_slug#mam-}"
|
|
if [ -n "$agent" ]; then
|
|
printf '%s-%s-%s' "$slug" "$role" "$agent"
|
|
else
|
|
printf '%s-%s-' "$slug" "$role"
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# env_python <yaml_path> [KEY=VALUE ...] (Python source read from stdin)
|
|
#
|
|
# Run python3 with the source supplied on stdin via a *quoted* heredoc, so the
|
|
# shell never interpolates the source. All values are passed through the
|
|
# environment (YAML_PATH plus any KEY=VALUE pairs). Untrusted data (workspace
|
|
# paths, capture-pane text) must travel as env vars and be read via os.environ
|
|
# inside the script — never spliced into the source. Read-only by convention;
|
|
# use atomic_dump_yaml when you need to write the YAML.
|
|
# ---------------------------------------------------------------------------
|
|
_validate_env_key() {
|
|
local key="$1"
|
|
if [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
|
echo "ERROR: Invalid environment variable name: $key" >&2
|
|
return 1
|
|
fi
|
|
case "$key" in
|
|
LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PYTHONHOME|PYTHONINSPECT|PYTHONSTARTUP)
|
|
echo "ERROR: Blocked environment variable: $key" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
env_python() {
|
|
local yaml_path="$1"; shift
|
|
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
local key="${1%%=*}"
|
|
_validate_env_key "$key" || return 1
|
|
envs+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
local pybin
|
|
pybin="$(_delegate_py_bin)"
|
|
env "${envs[@]}" "$pybin" - "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# atomic_dump_yaml <yaml_path> [KEY=VALUE ...] (mutation source from stdin)
|
|
#
|
|
# The ONLY sanctioned way to write agent-sessions.yaml. It:
|
|
# 1. takes an exclusive SQLite BEGIN IMMEDIATE transaction lock on
|
|
# agent-sessions.db (serialises all writers)
|
|
# 2. loads the current state into `d` (seeds from YAML if DB is empty)
|
|
# 3. exec()s the caller's mutation source (sees d, yaml, os, datetime,
|
|
# timezone, glob, subprocess; reads values via os.environ). The mutation
|
|
# may print and may `raise SystemExit(n)` to abort *without* writing.
|
|
# 4. validates the resulting schema
|
|
# 5. backs up to <yaml_path>.bak, then writes YAML atomically (temp + os.replace)
|
|
# when a session transitions to a finished state.
|
|
#
|
|
# The mutation source is passed via env and exec()'d — it is never string
|
|
# spliced and untrusted data never lands in Python source (P0-B / P1-B).
|
|
# ---------------------------------------------------------------------------
|
|
# Check if the workspace is on NFS — locking behaves differently on NFS
|
|
_check_is_nfs() {
|
|
local f="$1"
|
|
local mountpoint
|
|
mountpoint="$(df --output=target "$f" 2>/dev/null | tail -1)"
|
|
if [ -z "$mountpoint" ]; then
|
|
mountpoint="$(df -P "$f" 2>/dev/null | tail -1 | awk '{print $6}')"
|
|
fi
|
|
if [ -n "$mountpoint" ] && mount | grep -i -q -E "$mountpoint.*(nfs|cifs|smb|sshfs)"; then
|
|
return 0 # is NFS
|
|
fi
|
|
return 1 # not NFS
|
|
}
|
|
|
|
atomic_dump_yaml() {
|
|
local yaml_path="$1"; shift
|
|
if [ -z "${MAM_IS_NFS:-}" ]; then
|
|
if _check_is_nfs "$(dirname "$yaml_path")"; then
|
|
export MAM_IS_NFS="true"
|
|
echo "WARNING: $(dirname "$yaml_path") appears to be a network filesystem (NFS/CIFS/SSHFS)." >&2
|
|
echo "WARNING: SQLite journal_mode automatically falls back to DELETE." >&2
|
|
else
|
|
export MAM_IS_NFS="false"
|
|
fi
|
|
fi
|
|
|
|
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "MAM_IS_NFS=$MAM_IS_NFS" "PYTHONPATH=$SKILL_DIR:${PYTHONPATH:-}")
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
local key="${1%%=*}"
|
|
_validate_env_key "$key" || return 1
|
|
envs+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
local mutation=""; mutation="$(cat)"
|
|
local pybin; pybin="$(_delegate_py_bin)"
|
|
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" "$pybin" - <<'PYEOF'
|
|
from lib_py.atomic_yaml import atomic_dump_yaml_main
|
|
atomic_dump_yaml_main()
|
|
PYEOF
|
|
}
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
|
|
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
|
|
# ---------------------------------------------------------------------------
|
|
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
|
|
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
|
|
else
|
|
echo "ERROR: lib_py/verify_session.py missing" >&2
|
|
return 1 2>/dev/null || exit 1
|
|
fi
|
|
|
|
verify_session_uuid() {
|
|
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
|
|
WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, json
|
|
from lib_py.verify_session import verify_session_uuid
|
|
ws = os.environ['WS_ABS']
|
|
agent = os.environ['AGENT']
|
|
uuid = os.environ['UUID']
|
|
row_str = os.environ.get('ROW_JSON', '')
|
|
row = json.loads(row_str) if row_str else {}
|
|
mode = os.environ.get('MODE', 'discover')
|
|
if verify_session_uuid(ws, agent, uuid, row, mode=mode):
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# verify_tui_viewport <session_name> <agent> <workspace>
|
|
#
|
|
# Viewport matching verification:
|
|
# | Return | Meaning | Action Required |
|
|
# |---|---|---|
|
|
# | 0 | Viewport matches workspace | Pin UUID, last_visible_status='pinned', drift class C |
|
|
# | 1 | Viewport mismatch | Do not pin, log C-warn, last_visible_status unchanged |
|
|
# | 2 | Viewport check unavailable | Pin UUID via stage 1-3 only, log C-degraded |
|
|
#
|
|
# Returns:
|
|
# 0 = verified match
|
|
# 1 = mismatch
|
|
# 2 = check not possible (capture failed, session gone)
|
|
verify_tui_viewport() {
|
|
local session_name="$1" agent="$2" workspace="$3"
|
|
local abs; abs="$(mam_abs_workspace "$workspace")"
|
|
local base; base="$(basename "$abs")"
|
|
|
|
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
|
|
return 2
|
|
fi
|
|
|
|
local pane_content
|
|
pane_content=$(_pane_capture "$session_name" 2>/dev/null || true)
|
|
if [ -z "$pane_content" ]; then
|
|
return 2
|
|
fi
|
|
|
|
local flat base_flat
|
|
flat=$(printf '%s' "$pane_content" | tr -d '[:space:]')
|
|
base_flat=$(printf '%s' "$base" | tr -d '[:space:]')
|
|
if printf '%s' "$flat" | grep -Fq "$base_flat"; then
|
|
return 0
|
|
fi
|
|
if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then
|
|
return 1
|
|
fi
|
|
return 2
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# find_workspace_uuid <workspace> <agent>
|
|
#
|
|
# Workspace-SCOPED resolution of the resume UUID (P0-C). Resolution order:
|
|
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
|
|
# (claude_session_id_own / agy_conversation_id_own)
|
|
# 2) on-disk scan scoped to this workspace, via the agent adapter's discover()
|
|
# Prints the UUID on stdout (empty line if none). Always exits 0.
|
|
# ---------------------------------------------------------------------------
|
|
find_workspace_uuid() {
|
|
local workspace="$1" agent="$2" session_name="${3:-}"
|
|
local abs; abs="$(mam_abs_workspace "$workspace")"
|
|
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
from lib_py.workspace_uuid import find_workspace_uuid_main
|
|
find_workspace_uuid_main()
|
|
PYEOF
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# capture_conversation_id <agent> <workdir> [session_name]
|
|
#
|
|
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
|
|
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
|
|
# if none). find_workspace_uuid is already a workspace-scoped, 2-tier, race-free
|
|
# resolver (per-row own id -> workspace-scoped disk scan),
|
|
# so recording its result into the row before kill guarantees tier-1 on the next
|
|
# resume. Pass session_name to prefer that specific row's recorded id. Always exits 0.
|
|
# ---------------------------------------------------------------------------
|
|
capture_conversation_id() {
|
|
local agent="$1" workdir="$2" session_name="${3:-}"
|
|
find_workspace_uuid "$workdir" "$agent" "$session_name"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session isolation — Universal Global Config (Option B, P3-1)
|
|
#
|
|
# Config-home isolation (.mam/agent_homes/<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; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
|
|
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
|
|
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
|
|
[ -n "$att" ] && argv+=(--attempt-id "$att")
|
|
local out
|
|
out="$(
|
|
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
|
printf '%q ' "${argv[@]}"
|
|
)"
|
|
printf '%s' "${out% }"
|
|
}
|
|
|
|
# delegate_job_status <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_started_recorded() {
|
|
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
|
|
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
|
[ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}"
|
|
local ev_log="$reg_dir/$job_id.events.log"
|
|
local audit="$logs_dir/$job_id/events.ndjson"
|
|
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
|
|
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
|
|
import hashlib, json, os, sys
|
|
tok = os.environ.get("EV_TOK", "")
|
|
fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else ""
|
|
|
|
def lines(path):
|
|
if not os.path.isfile(path):
|
|
return
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
try:
|
|
e = json.loads(line)
|
|
except Exception:
|
|
continue
|
|
if isinstance(e, dict):
|
|
yield e
|
|
|
|
for e in lines(os.environ["EV_LOG"]):
|
|
if e.get("event") != "started":
|
|
continue
|
|
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
|
if not tok or data.get("dispatch_token") == tok:
|
|
sys.exit(0)
|
|
for e in lines(os.environ["EV_AUDIT"]):
|
|
if e.get("event") != "received" or e.get("source_event") != "started":
|
|
continue
|
|
if not tok:
|
|
sys.exit(0)
|
|
if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp:
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
' 2>/dev/null
|
|
}
|
|
|
|
# mam_int / mam_int_knob (Issue #26) live in lib_int.sh, shared with the
|
|
# scripts that run without lib.sh.
|
|
# shellcheck source=lib_int.sh
|
|
source "$(dirname "${BASH_SOURCE[0]}")/lib_int.sh"
|
|
|
|
# delegate_wait_started <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
|
|
deadline=$(( $(date +%s) + ack_s ))
|
|
while :; do
|
|
if delegate_started_recorded "$job_id" "$tok"; then
|
|
return 0
|
|
fi
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# 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_onboard_exit <status>
|
|
# Issue #50: create's exit code for an onboarding status (0, 44 or 45).
|
|
delegate_onboard_exit() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.wrapper onboard-exit "$1"
|
|
}
|
|
|
|
# ensure_subscriber_daemon <job_id> [workdir]
|
|
# Start the monitor subscriber for this workspace unless one is already running.
|
|
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
|
ensure_subscriber_daemon() {
|
|
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
|
|
_delegation_py ensure-daemon --job "$1" --workdir "${2:-$PWD}"
|
|
}
|
|
|
|
# start_watchdog — retained name; create_session.sh and operator docs use it.
|
|
start_watchdog() { ensure_subscriber_daemon "$@"; }
|
|
|
|
# wait_for_tui_ready <session_name> <agent>
|
|
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
|
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
|
|
wait_for_tui_ready() {
|
|
local sess="$1" agent="$2"
|
|
local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
|
local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
|
local placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
|
local prompt="${MAM_INPUT_PROMPT:-}"
|
|
local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
|
local modal_tokens="${MAM_MODAL_TOKENS:-}"
|
|
|
|
# Self-contained resolution: fetch facts in 1 call if not already in environment
|
|
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then
|
|
local _facts=""
|
|
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts=""
|
|
if [ -n "$_facts" ]; then
|
|
eval "$_facts"
|
|
strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
|
|
weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
|
|
placeholder="${MAM_INPUT_PLACEHOLDER:-}"
|
|
prompt="${MAM_INPUT_PROMPT:-}"
|
|
rule_pattern="${MAM_INPUT_RULE_PATTERN:-}"
|
|
modal_tokens="${MAM_MODAL_TOKENS:-}"
|
|
fi
|
|
fi
|
|
# Fallback if strong_tokens is empty but MAM_READY_TOKENS is set
|
|
if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then
|
|
strong_tokens="$MAM_READY_TOKENS"
|
|
fi
|
|
|
|
if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then
|
|
echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
|
|
return 1
|
|
fi
|
|
|
|
local empty_streak=0
|
|
local empty_giveup="$(mam_int_knob MAM_READY_EMPTY_GIVEUP 5 1)"
|
|
local modal_pat="$_MAM_MODAL_TOKENS"
|
|
[ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}"
|
|
|
|
local i content tail_lines
|
|
for i in {1..30}; do
|
|
# 1. Single capture per iteration (T-3)
|
|
content=$(_pane_capture "$sess")
|
|
|
|
# Consecutive blank capture check (C-6)
|
|
if [ -z "$content" ]; then
|
|
empty_streak=$((empty_streak + 1))
|
|
if [ "$empty_streak" -ge "$empty_giveup" ]; then
|
|
echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2
|
|
return 2
|
|
fi
|
|
sleep 1
|
|
continue
|
|
fi
|
|
empty_streak=0
|
|
|
|
# 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
|
|
# handle it and do NOT declare ready on this iteration.
|
|
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
|
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
|
handle_startup_dialogs "$sess" 1 "$agent" || true
|
|
sleep 1
|
|
continue
|
|
fi
|
|
|
|
# 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first)
|
|
local s_matched=false
|
|
local w_matched=false
|
|
local c_matched=false
|
|
|
|
# S-layer: Strong ready token match
|
|
if [ -n "$strong_tokens" ]; then
|
|
if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then
|
|
s_matched=true
|
|
fi
|
|
fi
|
|
|
|
# S-layer: Placeholder match (must be >= 8 characters)
|
|
if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then
|
|
if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then
|
|
s_matched=true
|
|
fi
|
|
fi
|
|
|
|
# W-layer: Weak ready token match
|
|
if [ -n "$weak_tokens" ]; then
|
|
if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then
|
|
w_matched=true
|
|
fi
|
|
fi
|
|
|
|
# C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match)
|
|
if [ -n "$prompt" ]; then
|
|
if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then
|
|
c_matched=true
|
|
fi
|
|
fi
|
|
if [ -n "$rule_pattern" ]; then
|
|
if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then
|
|
c_matched=true
|
|
fi
|
|
fi
|
|
|
|
# Decision: S OR (W AND C)
|
|
if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then
|
|
echo "✅ $agent TUI detected ready."
|
|
return 0
|
|
fi
|
|
|
|
# 4. Startup dialog & hint resolution if not ready
|
|
handle_startup_dialogs "$sess" 1 "$agent" || true
|
|
sleep 1
|
|
done
|
|
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
|
return 1
|
|
}
|
|
|
|
# inject_instructions <session_name> <instructions> [job_id]
|
|
# Injects instructions into the herdr session using paste-buffer and C-m.
|
|
# Delegates to send_keys_safe to ensure focus and renderer correctness (MS-5).
|
|
inject_instructions() {
|
|
send_keys_safe "$1" "$2" "${3:-onboard}"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompt-lock safe delivery (FW-W2). Keys are sent on evidence, not timers.
|
|
# send_keys_safe returns 0 only if the text was verifiably submitted.
|
|
# Exit codes: 1=pane never quiesced 2=dialog blocking input
|
|
# 3=paste not visible 4=Enter not accepted
|
|
# Callers MUST handle non-zero.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Server-aware herdr (same isolation rule as inject_instructions).
|
|
_sks_herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
_pane_capture() {
|
|
local raw
|
|
raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")"
|
|
if [ -z "$raw" ]; then
|
|
echo ""
|
|
return 0
|
|
fi
|
|
python3 -c '
|
|
import sys, json
|
|
raw = sys.stdin.read()
|
|
try:
|
|
data = json.loads(raw)
|
|
text = data.get("result", {}).get("read", {}).get("text")
|
|
if text is not None:
|
|
print(text, end="")
|
|
else:
|
|
print(raw, end="")
|
|
except Exception:
|
|
print(raw, end="")
|
|
' <<< "$raw"
|
|
}
|
|
|
|
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
|
|
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
|
|
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
|
|
|
|
# _sks_agent_status <sess> [target] — herdr agent_status, empty when unavailable.
|
|
_sks_agent_status() {
|
|
local raw
|
|
raw="$(_sks_herdr agent get "${2:-$1}" 2>/dev/null || _sks_herdr agent get "$1" 2>/dev/null || true)"
|
|
[ -n "$raw" ] || { echo ""; return 0; }
|
|
"$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$raw" 2>/dev/null || true
|
|
}
|
|
|
|
# _sks_job_started <job_id> — Issue #18 delivery signal: the job's own
|
|
# `started` (registry status left `pending`, or a started line in the job's
|
|
# events.log). Reads the registry the wrapper exports (SKS_REGISTRY_DIR), else
|
|
# MAM_JOBS_DIR, else $WORKSPACE_ROOT/.mam/jobs. "adhoc" (no job) is never started.
|
|
_sks_job_started() {
|
|
local job_id="${1:-adhoc}" rd
|
|
[ -n "$job_id" ] && [ "$job_id" != "adhoc" ] || return 1
|
|
rd="${SKS_REGISTRY_DIR:-${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}}"
|
|
"$(_delegate_py_bin)" - "$rd" "$job_id" <<'PYEOF'
|
|
import json, os, sys
|
|
rd, jid = sys.argv[1], sys.argv[2]
|
|
rec = os.path.join(rd, jid + ".json")
|
|
try:
|
|
st = json.load(open(rec)).get("status")
|
|
if st and st != "pending":
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
log = os.path.join(rd, jid + ".events.log")
|
|
try:
|
|
with open(log) as fh:
|
|
for line in fh:
|
|
try:
|
|
if json.loads(line).get("event") == "started":
|
|
sys.exit(0)
|
|
except Exception:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# _wait_session_gone <sess> [max_sec=5]
|
|
# Reactive loop to wait until a herdr session goes away (happy path returns early).
|
|
_wait_session_gone() {
|
|
local sess="$1" max="${2:-5}" i
|
|
for ((i = 0; i < max * 4; i++)); do
|
|
_sks_herdr has-session -t "$sess" 2>/dev/null || return 0
|
|
sleep 0.25
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# _pane_quiescent <sess> [tries=20] [interval=0.5] # empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)
|
|
# Renderer settled = two consecutive identical non-empty captures.
|
|
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
|
# Returns 0 if renderer settled (two identical non-empty captures).
|
|
# Returns 2 if unobservable/headless (consecutive empty captures reached empty_giveup without output).
|
|
# Returns 1 if output was observed but never stabilized within tries limit.
|
|
_pane_quiescent() {
|
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
|
local saw_output=0 empty_streak=0
|
|
local empty_giveup="$(mam_int_knob SKS_EMPTY_GIVEUP 3 1)"
|
|
for ((i = 0; i < tries; i++)); do
|
|
cur=$(_pane_capture "$sess")
|
|
if [ -z "$cur" ]; then
|
|
empty_streak=$((empty_streak + 1))
|
|
[ "$saw_output" = "0" ] && [ "$empty_streak" -ge "$empty_giveup" ] && return 2
|
|
sleep "$interval"
|
|
continue
|
|
fi
|
|
saw_output=1
|
|
empty_streak=0
|
|
[ "$cur" = "$prev" ] && return 0
|
|
prev="$cur"
|
|
sleep "$interval"
|
|
done
|
|
[ "$saw_output" = "0" ] && return 2
|
|
return 1
|
|
}
|
|
|
|
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
|
|
# permission / OAuth / list-selection), checked in the bottom 8 pane lines
|
|
# only (dialogs render near the input area; conversation text above must not
|
|
# trigger this). Tokens must NOT appear on normal idle prompt screens.
|
|
_pane_dialog_open() {
|
|
local extra="${MAM_MODAL_TOKENS:-}"
|
|
local pat="$_MAM_MODAL_TOKENS"
|
|
[ -n "$extra" ] && pat="${pat}|${extra}"
|
|
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
|
|
}
|
|
|
|
# wait_for_agent_idle <sess> [timeout_ms]
|
|
# Phase 2 사전 TUI 유휴 핸드셰이크:
|
|
# 0: idle / done (성공)
|
|
# 1: agent still working (최종 타임아웃)
|
|
# 2: blocked (대화창/모달 차단)
|
|
# 3: unknown (휴리스틱 폴백 신호)
|
|
wait_for_agent_idle() {
|
|
local sess="${1#herdr:}"
|
|
local timeout_ms="${2:-${SKS_IDLE_TIMEOUT_MS:-120000}}"
|
|
|
|
if [ "${SKS_IDLE_HANDSHAKE:-1}" = "0" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local target
|
|
target=$(_sanitize_herdr_agent_name "$sess")
|
|
|
|
local raw_status="" agent_st=""
|
|
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
|
|
if [ -n "$raw_status" ]; then
|
|
agent_st="$("$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$raw_status" 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [ -z "$agent_st" ] || [ "$agent_st" = "unknown" ]; then
|
|
return 3
|
|
fi
|
|
if [ "$agent_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
if [ "$agent_st" = "idle" ] || [ "$agent_st" = "done" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local max_retries="$(mam_int_knob SKS_IDLE_RETRIES 2 0)"
|
|
local backoff_s="$(mam_int_knob SKS_IDLE_RETRY_BACKOFF_S 10 0)"
|
|
local try=0
|
|
while [ "$try" -le "$max_retries" ]; do
|
|
local watcher_out="" watcher_rc=0
|
|
watcher_out="$("$(_delegate_py_bin)" -m lib_py.loop.watcher wait "$target" \
|
|
--session "${HERDR_SESSION_NAME:-default}" \
|
|
--until idle --until done --until blocked \
|
|
--timeout-ms "$timeout_ms" 2>/dev/null)" || watcher_rc=$?
|
|
|
|
local parsed_st=""
|
|
if [ -n "$watcher_out" ]; then
|
|
parsed_st="$("$(_delegate_py_bin)" -c '
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print(d.get("agent_status", "") or "")
|
|
except Exception:
|
|
print("")
|
|
' <<< "$watcher_out" 2>/dev/null || true)"
|
|
fi
|
|
|
|
if [ $watcher_rc -eq 0 ]; then
|
|
if [ "$parsed_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
return 0
|
|
elif [ $watcher_rc -eq 2 ]; then
|
|
if [ "$parsed_st" = "blocked" ]; then
|
|
return 2
|
|
fi
|
|
if [ "$try" -lt "$max_retries" ]; then
|
|
sleep "$backoff_s"
|
|
try=$((try + 1))
|
|
continue
|
|
else
|
|
return 1
|
|
fi
|
|
else
|
|
return 3
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# send_keys_safe <sess> <text> [job_id]
|
|
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
|
|
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
|
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
|
# Escape per poll and re-check. NEVER a blind Enter.
|
|
# 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission.
|
|
# An accepted submission whose status watch timed out is confirmed by polling
|
|
# for SKS_DELIVERY_GRACE_S (default 15 s) - see ISSUE-14 below.
|
|
# 4. Paste via unique buffer; verify the text landed (marker visible).
|
|
# 5. Submit C-m; verify submission (marker left the input area AND the pane
|
|
# changed); retry up to 3 times.
|
|
send_keys_safe() {
|
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
|
local pre_submit deadline try
|
|
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
|
|
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
|
|
|
|
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
|
|
local _sks_agent=""
|
|
case "$sess" in
|
|
*-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;;
|
|
*-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;;
|
|
*-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;;
|
|
*-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;;
|
|
*-opencode|*-opencode-[0-9]*|opencode|opencode-[0-9]*) _sks_agent="opencode" ;;
|
|
*-codex|*-codex-[0-9]*|codex|codex-[0-9]*) _sks_agent="codex" ;;
|
|
esac
|
|
if [ -n "$_sks_agent" ]; then
|
|
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)"
|
|
fi
|
|
fi
|
|
|
|
local _q_rc=0
|
|
_pane_quiescent "$sess" "$(mam_int_knob SKS_QUIESCENT_TRIES 20 0)" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
|
if [ "$_q_rc" = "1" ]; then
|
|
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [ "$_q_rc" != "2" ]; then
|
|
deadline=$(( $(date +%s) + $(mam_int_knob SKS_DIALOG_TIMEOUT 30 0) ))
|
|
while _pane_dialog_open "$sess"; do
|
|
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
|
_sks_herdr send-keys -t "$sess" Escape
|
|
sleep 1
|
|
fi
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
|
return 2
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
|
|
local agent_target
|
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
|
|
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
|
|
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
|
|
local b_deadline=$(( $(date +%s) + $(mam_int_knob SKS_BUSY_TIMEOUT 120 0) ))
|
|
local still_busy=1
|
|
while [ "$(date +%s)" -lt "$b_deadline" ]; do
|
|
local tail_pane
|
|
tail_pane=$(_pane_tail "$sess" 8)
|
|
if ! printf '%s\n' "$tail_pane" | grep -Fq "esc to interrupt" && ! printf '%s\n' "$tail_pane" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
|
still_busy=0
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$still_busy" -eq 1 ]; then
|
|
echo "send_keys_safe: pane still busy after SKS_BUSY_TIMEOUT ($sess)" >&2
|
|
return 4
|
|
fi
|
|
fi
|
|
|
|
# Native herdr 0.8+ fast path: agent prompt handles atomic submission
|
|
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
|
|
local p_out="" p_rc=0
|
|
local _p_tmp
|
|
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$")
|
|
if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then
|
|
rm -f "$_p_tmp" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
p_rc=$?
|
|
p_out=$(cat "$_p_tmp" 2>/dev/null || true)
|
|
rm -f "$_p_tmp" 2>/dev/null || true
|
|
|
|
if printf '%s\n' "$p_out" | grep -Fq "agent_not_found"; then
|
|
local _p_tmp2
|
|
_p_tmp2=$(mktemp 2>/dev/null || echo "/tmp/sks_p2_${sess}_$$")
|
|
if _sks_herdr agent prompt "$sess" "$text" --wait --until working --timeout 5000 >"$_p_tmp2" 2>&1; then
|
|
rm -f "$_p_tmp2" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
p_rc=$?
|
|
p_out=$(cat "$_p_tmp2" 2>/dev/null || true)
|
|
rm -f "$_p_tmp2" 2>/dev/null || true
|
|
fi
|
|
if printf '%s\n' "$p_out" | grep -Fq "agent_blocked"; then
|
|
echo "send_keys_safe: agent blocked ($sess)" >&2
|
|
return 2
|
|
fi
|
|
|
|
# Only fall through to legacy paste-buffer if agent prompt command itself is not supported
|
|
# or if target agent is not found (generic pane / terminal)
|
|
if printf '%s\n' "$p_out" | grep -Eq "unknown command|unrecognized command|no such command|unexpected argument|unrecognized option|agent_not_found"; then
|
|
: # Fall through to legacy paste buffer
|
|
else
|
|
# Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.),
|
|
# do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
|
|
sleep 2
|
|
if [ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ]; then
|
|
return 0
|
|
fi
|
|
# ISSUE-14 / ISSUE-18: `timeout` / `agent_prompt_stalled` mean herdr
|
|
# ACCEPTED the submission and sent the input - only its post-submit
|
|
# status watch came up empty (codex under herdr 0.9.0 never reports
|
|
# `working`). For an accepted submission keep observing for
|
|
# SKS_DELIVERY_GRACE_S (default 15) and take a *protocol* signal only:
|
|
# agent_status working, or the job's own `started` in the registry
|
|
# (_sks_job_started). Pane changes and busy tokens are no longer
|
|
# delivery evidence (Issue #18: they are CLI-version-specific screen
|
|
# scraping that a headless or remote agent never produces). No signal
|
|
# still fails closed (rc 4); the wrapper then waits for `started` up to
|
|
# the job's ack timeout T before finalising the inject as failed.
|
|
if printf '%s\n' "$p_out" | grep -Eq '"code": *"(timeout|agent_prompt_stalled)"'; then
|
|
local g_deadline=$(( $(date +%s) + $(mam_int_knob SKS_DELIVERY_GRACE_S 15 0) ))
|
|
while [ "$(date +%s)" -lt "$g_deadline" ]; do
|
|
[ "$(_sks_agent_status "$sess" "$agent_target")" = "working" ] && return 0
|
|
_sks_job_started "$job_id" && return 0
|
|
sleep 1
|
|
done
|
|
fi
|
|
echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
|
|
return 4
|
|
fi
|
|
else
|
|
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Fallback: paste buffer submission. Compute verification markers on demand.
|
|
local marker marker_norm
|
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
|
# the properly-decoded rendered pane text) of the last non-empty line.
|
|
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')")
|
|
# Whitespace-normalized copy for matching: some TUIs don't just soft-wrap
|
|
# with a bare newline — they add a leading-space "hanging indent" on the
|
|
# continuation line too, so removing only '\n' still leaves an extra space
|
|
# that breaks an exact literal match. Matching with all whitespace collapsed
|
|
# out sidesteps wrap formatting entirely, whatever shape it takes.
|
|
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
|
|
|
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
|
|
_sks_herdr set-buffer -b "$sks_buf" "$text"
|
|
local _paste_rc=0
|
|
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
|
|
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
|
|
if [ "$_paste_rc" != "0" ]; then
|
|
echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
|
|
return 3
|
|
fi
|
|
local was_popup=0
|
|
if [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]] || [[ "$sess" =~ "grok" ]]; then
|
|
# Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
|
|
true
|
|
else
|
|
sleep 0.5
|
|
local pane_content
|
|
pane_content=$(_pane_capture "$sess")
|
|
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
|
was_popup=1
|
|
# Strip ALL whitespace before matching — the rendered pane soft-wraps long
|
|
# lines at the terminal width (and some TUIs add indentation on the
|
|
# continuation line too), which can split/reformat the marker across two
|
|
# visual lines and make a literal grep miss it even though the paste landed.
|
|
elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then
|
|
echo "send_keys_safe: paste not visible ($sess)" >&2
|
|
return 3
|
|
fi
|
|
fi
|
|
|
|
for try in 1 2 3; do
|
|
pre_submit=$(_pane_capture "$sess")
|
|
_sks_herdr send-keys -t "$sess" C-m
|
|
sleep "$try"
|
|
local cur_content
|
|
cur_content=$(_pane_capture "$sess")
|
|
# Hardened Submission Checks: exclude busy tokens if already present in pre_submit
|
|
local pre_busy=0
|
|
if printf '%s\n' "$pre_submit" | grep -Fq "esc to interrupt" || printf '%s\n' "$pre_submit" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
|
|
pre_busy=1
|
|
fi
|
|
if [ "$pre_busy" -eq 0 ] && ( printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing" ); then
|
|
return 0
|
|
fi
|
|
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
elif [ "$was_popup" = "1" ] && \
|
|
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
|
[ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
fi
|
|
done
|
|
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
|
|
return 4
|
|
}
|
|
|
|
# _mam_load_dialog_facts <agent>
|
|
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell, and the
|
|
# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller
|
|
# has not set them. `facts` is an eval contract (F8); evaluating all of it here
|
|
# would clobber tokens a caller deliberately set, so only these are taken, and
|
|
# the tokens only fill a gap. Without them the handler could never recognise a
|
|
# ready screen when called on its own - resume_session.sh does exactly that,
|
|
# with no wait_for_tui_ready before it - and idled through its whole timeout on
|
|
# every resume (review B-1). An agent the adapters do not know keeps an empty
|
|
# list and no tokens.
|
|
_mam_load_dialog_facts() {
|
|
local agent="$1" _f="" _d="" _s="" _r=""
|
|
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
|
|
return 0
|
|
fi
|
|
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f=""
|
|
_d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')"
|
|
_s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')"
|
|
_r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
|
if [ -n "$_d" ]; then
|
|
eval "MAM_STARTUP_DIALOGS=$_d"
|
|
else
|
|
MAM_STARTUP_DIALOGS='[]'
|
|
fi
|
|
if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then
|
|
eval "MAM_STRONG_READY_TOKENS=$_s"
|
|
fi
|
|
if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then
|
|
eval "MAM_READY_TOKENS=$_r"
|
|
fi
|
|
_MAM_DIALOGS_AGENT="$agent"
|
|
return 0
|
|
}
|
|
|
|
# _mam_dialog_pick — first actionable dialog on this frame.
|
|
# stdout: "send|<idx>|<key> <key>..." or "skip|<idx>|", nothing when there is
|
|
# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts).
|
|
_mam_dialog_pick() {
|
|
MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \
|
|
MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \
|
|
python3 -c '
|
|
import os, re, sys, json
|
|
pane = os.environ.get("MAM_DLG_PANE", "")
|
|
try:
|
|
dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]")
|
|
except Exception:
|
|
dialogs = []
|
|
auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"
|
|
try:
|
|
now = int(os.environ.get("MAM_DLG_NOW") or 0)
|
|
cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5)
|
|
except ValueError:
|
|
sys.exit(1)
|
|
state = {}
|
|
for tok in (os.environ.get("MAM_DLG_STATE") or "").split():
|
|
k, _, v = tok.partition(":")
|
|
try:
|
|
state[int(k)] = int(v)
|
|
except ValueError:
|
|
pass
|
|
for idx, entry in enumerate(dialogs):
|
|
try:
|
|
pattern, keys, kind = entry[0], entry[1], entry[2]
|
|
if not re.search(pattern, pane):
|
|
continue
|
|
except Exception:
|
|
continue
|
|
last = state.get(idx)
|
|
if last is not None and 0 <= now - last < cooldown:
|
|
# rate-limited: look at the other dialogs on this screen instead of
|
|
# giving up (merge review, codex probe: a skipped trust prompt or a
|
|
# freshly answered one used to hide the notice below it)
|
|
continue
|
|
if kind == "trust" and not auto_trust:
|
|
print("skip|%d|" % idx)
|
|
sys.exit(0)
|
|
print("send|%d|%s" % (idx, " ".join(keys)))
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
'
|
|
}
|
|
|
|
# handle_startup_dialogs <sess> [timeout_sec=20] [agent=claude]
|
|
# Answer the blocking modals an agent shows before its TUI is usable. The
|
|
# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS),
|
|
# so this is agent-agnostic - issue #24: it used to be a claude-only ladder
|
|
# called only for claude, and agy/grok/codex sat on their trust prompt until
|
|
# the readiness wait timed out and onboarding was skipped.
|
|
#
|
|
# `--source visible` keeps showing an accepted dialog for a while (#24
|
|
# comment), so a signature still on screen is NOT evidence that the keys were
|
|
# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S
|
|
# (default 5s) and acceptance is judged only by the ready tokens.
|
|
#
|
|
# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per
|
|
# cooldown); `notice` dialogs are always answered.
|
|
handle_startup_dialogs() {
|
|
local sess="$1" timeout="${2:-20}" agent="${3:-claude}"
|
|
local waited=0 pane pick action rest idx keys key now
|
|
local cooldown="${MAM_DIALOG_RESEND_S:-5}"
|
|
local auto_trust="${MAM_AUTO_TRUST:-1}"
|
|
_mam_load_dialog_facts "$agent"
|
|
local dialogs="${MAM_STARTUP_DIALOGS:-[]}"
|
|
local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}"
|
|
while [ "$waited" -lt "$timeout" ]; do
|
|
pane=$(_pane_tail "$sess" 20)
|
|
now=$(date +%s)
|
|
pick=""
|
|
pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true)
|
|
if [ -n "$pick" ]; then
|
|
action="${pick%%|*}"
|
|
rest="${pick#*|}"
|
|
idx="${rest%%|*}"
|
|
keys="${rest#*|}"
|
|
if [ "$action" = "send" ]; then
|
|
for key in $keys; do
|
|
_sks_herdr send-keys -t "$sess" "$key" || true
|
|
sleep 0.3
|
|
done
|
|
else
|
|
echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2
|
|
fi
|
|
_MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now"
|
|
elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
waited=$((waited + 1))
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Auto-initialize the herdr translation shim wrapper on library source
|
|
_init_herdr_isolation
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue #21 (item 3): control-plane binding of a session row. The logic lives
|
|
# in lib_py/control_plane.py; these are thin shell entry points.
|
|
# mam_control_plane_json <workspace> [<row control_plane JSON>]
|
|
mam_control_plane_json() {
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@"
|
|
}
|
|
|
|
# mam_control_plane_prefix <json>: the leading K=V tokens for new-session, or
|
|
# nothing when no forward is needed. rc 3: a forward is needed but a path
|
|
# contains whitespace (lib.sh splits the --env flags unquoted).
|
|
mam_control_plane_prefix() {
|
|
[ -n "${1:-}" ] || return 0
|
|
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1"
|
|
}
|