1723 lines
60 KiB
Bash
1723 lines
60 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)"
|
|
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
|
|
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
|
|
|
# Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells
|
|
for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME/.local/bin" "$HOME/.npm-global/bin"; do
|
|
if [ -d "$dir" ] && [[ ":$PATH:" != *":$dir:"* ]]; then
|
|
export PATH="$PATH:$dir"
|
|
fi
|
|
done
|
|
|
|
# Central TUI dialog and readiness validation tokens (OP-6)
|
|
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
|
|
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|projects'
|
|
|
|
# Workspace-relative defaults with environment overrides (Phase Z)
|
|
HOME_DIR="${HOME_DIR:-$HOME}"
|
|
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
|
LOCAL_BIN="${LOCAL_BIN:-$HOME/.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_SERVER_NAME="${HERDR_SERVER_NAME:-default}"
|
|
|
|
_resolve_real_herdr_path() {
|
|
_REAL_HERDR_PATH="herdr"
|
|
export _REAL_HERDR_PATH
|
|
}
|
|
|
|
_init_herdr_isolation() {
|
|
local wrapper_dir="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$wrapper_dir"
|
|
if [ -x "$wrapper_dir/herdr" ]; then
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
local tmp_file
|
|
tmp_file=$(mktemp "$wrapper_dir/herdr.XXXXXX")
|
|
cat <<'EOF' > "$tmp_file"
|
|
#!/usr/bin/env bash
|
|
# Herdr-to-Herdr translation shim wrapper
|
|
set -euo pipefail
|
|
|
|
# Dynamic real herdr path resolution to prevent infinite recursion
|
|
_resolve_real_herdr() {
|
|
local dir save_ifs="$IFS" real_path=""
|
|
IFS=:
|
|
for dir in $PATH; do
|
|
if [[ "$dir" != *".mam/shim"* ]] && [[ "$dir" != *"-shim"* ]] && [ -x "$dir/herdr" ]; then
|
|
real_path="$dir/herdr"
|
|
break
|
|
fi
|
|
done
|
|
IFS="$save_ifs"
|
|
if [ -z "$real_path" ]; then
|
|
real_path="herdr"
|
|
fi
|
|
echo "$real_path"
|
|
}
|
|
REAL_HERDR=$(_resolve_real_herdr)
|
|
|
|
# Support parsing -L <server> before the subcommand
|
|
while [ "${1:-}" = "-L" ]; do
|
|
if [ $# -lt 2 ]; then
|
|
echo "herdr shim: -L requires an argument" >&2
|
|
exit 1
|
|
fi
|
|
export HERDR_SERVER_NAME="$2"
|
|
shift 2
|
|
done
|
|
|
|
# Herdr's real isolation boundary is `--session <name>` (a whole separate
|
|
# server + socket, like tmux `-L`) — NOT `workspace create --label`, which is
|
|
# just a named subdivision inside ONE server and provides no actual isolation
|
|
# (agent/pane commands are server-global regardless of workspace). When
|
|
# HERDR_SERVER_NAME names a non-default session, make sure its headless server
|
|
# is actually running, then scope every real herdr call to it via `--session`.
|
|
_MAM_SESSION="${HERDR_SERVER_NAME:-default}"
|
|
if [ "$_MAM_SESSION" = "default" ]; then
|
|
_MAM_SESSION=""
|
|
else
|
|
_mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c "
|
|
import sys, json, os
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
name = os.environ.get('MAM_SESS', '')
|
|
print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0')
|
|
except Exception:
|
|
print('0')
|
|
" 2>/dev/null || echo "0")
|
|
if [ "$_mam_session_running" != "1" ]; then
|
|
# Headless bootstrap avoids herdr's "nested herdr is disabled" guard that
|
|
# blocks a normal interactive `herdr --session <name>` launch from inside
|
|
# an existing herdr pane (which is how MAM's own agents usually run).
|
|
nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 &
|
|
disown 2>/dev/null || true
|
|
for _mam_wait_i in $(seq 1 40); do
|
|
[ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break
|
|
sleep 0.25
|
|
done
|
|
fi
|
|
fi
|
|
|
|
_real_herdr() {
|
|
local session="${HERDR_SERVER_NAME:-}"
|
|
if [ "$session" = "default" ]; then
|
|
session=""
|
|
fi
|
|
if [ -n "$session" ]; then
|
|
"$REAL_HERDR" --session "$session" "$@"
|
|
else
|
|
"$REAL_HERDR" "$@"
|
|
fi
|
|
}
|
|
|
|
wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
cmd="${1:-}"
|
|
if [ -z "$cmd" ]; then
|
|
echo "herdr shim: no command specified" >&2
|
|
exit 1
|
|
fi
|
|
shift
|
|
|
|
|
|
case "$cmd" in
|
|
has-session)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
_real_herdr agent get "$sess" >/dev/null 2>&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|-x|-y) shift ;;
|
|
*) 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
|
|
|
|
# Check if there is an existing workspace inside this session (to reuse and split view)
|
|
existing_ws=$(_real_herdr workspace list 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
wss = d.get('result', {}).get('workspaces', [])
|
|
if wss:
|
|
print(wss[0].get('workspace_id', ''))
|
|
except Exception:
|
|
pass
|
|
")
|
|
|
|
split_flag=""
|
|
if [ -n "$existing_ws" ]; then
|
|
ws_id="$existing_ws"
|
|
split_flag="--split right"
|
|
else
|
|
ws_id=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
print(d.get('result', {}).get('workspace', {}).get('workspace_id', ''))
|
|
except Exception:
|
|
pass
|
|
")
|
|
ws_id="${ws_id:-w1}"
|
|
fi
|
|
|
|
eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $split_flag $env_flags -- $final_cmd"
|
|
;;
|
|
kill-session)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
# NOTE: herdr's `session` verb operates on whole server instances (see
|
|
# `herdr session list`), not individual agent panes — `session stop/delete
|
|
# "$sess"` with a MAM session name always fails (silently, via `|| true`).
|
|
# Resolve the real pane_id via `agent get` and close just that pane instead.
|
|
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
|
|
fi
|
|
;;
|
|
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>`.
|
|
info=$(_real_herdr agent get "$sess" 2>/dev/null || true)
|
|
pane_id="" cwd="" cmd=""
|
|
if [ -n "$info" ]; then
|
|
parsed=$(python3 -c "
|
|
import sys, json
|
|
try:
|
|
a = json.loads(sys.stdin.read()).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_current_command}')
|
|
printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd"
|
|
;;
|
|
'#{pane_pid}')
|
|
printf '%s\n' "$pid"
|
|
;;
|
|
'#{pane_current_path}')
|
|
printf '%s\n' "$cwd"
|
|
;;
|
|
'#{pane_current_command}')
|
|
printf '%s\n' "$cmd"
|
|
;;
|
|
esac
|
|
;;
|
|
capture-pane)
|
|
sess=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
_real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
|
|
;;
|
|
send-keys)
|
|
sess="" key=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) key="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then
|
|
key="Enter"
|
|
fi
|
|
# `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name —
|
|
# resolve it via `agent get` first (agent-level commands accept names).
|
|
pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', ''))
|
|
except Exception:
|
|
pass
|
|
" 2>/dev/null)
|
|
if [ -n "$pane_id" ]; then
|
|
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
|
|
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 buf="tmp_buffer"; else buf="buf_$buf"; fi
|
|
shift 2
|
|
;;
|
|
*) text="$1"; shift ;;
|
|
esac
|
|
done
|
|
echo -n "$text" > "$wrapper_dir/$buf"
|
|
;;
|
|
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 buf="tmp_buffer"; else buf="buf_$buf"; fi
|
|
shift 2
|
|
;;
|
|
-t)
|
|
if [ $# -lt 2 ]; then
|
|
echo "Error: -t requires a value" >&2
|
|
exit 1
|
|
fi
|
|
sess="$2"
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
if [ -f "$wrapper_dir/$buf" ]; then
|
|
_real_herdr agent send "$sess" "$(cat "$wrapper_dir/$buf")" >/dev/null 2>&1 || true
|
|
else
|
|
echo "Error: buffer $buf not found" >&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 buf="tmp_buffer"; else buf="buf_$buf"; fi
|
|
shift 2
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
rm -f "$wrapper_dir/$buf"
|
|
;;
|
|
ls)
|
|
_real_herdr agent list 2>/dev/null | python3 -c "
|
|
import sys, json
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
res = data.get('result', data)
|
|
for a in res.get('agents', []):
|
|
try:
|
|
name = a.get('name') or a.get('agent') or 'unknown'
|
|
print(f\"{name}|\")
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
" || true
|
|
;;
|
|
*)
|
|
_real_herdr "$cmd" "$@"
|
|
;;
|
|
esac
|
|
EOF
|
|
chmod +x "$tmp_file"
|
|
mv -f "$tmp_file" "$wrapper_dir/herdr"
|
|
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
|
export PATH="$wrapper_dir:$PATH"
|
|
fi
|
|
}
|
|
|
|
mam_herdr() {
|
|
_init_herdr_isolation
|
|
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
|
}
|
|
|
|
_herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
herdr() {
|
|
mam_herdr "$@"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_herdr_server <session_name>
|
|
#
|
|
# Query agent-sessions.yaml to find the herdr_server associated with a session.
|
|
# Fallback to HERDR_SERVER_NAME or 'default' if not registered or field is missing.
|
|
# Prints the resolved server name on stdout.
|
|
# ---------------------------------------------------------------------------
|
|
load_state_json() {
|
|
env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, sqlite3, json, yaml
|
|
yaml_path = os.environ['YAML_PATH']
|
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
d = {}
|
|
db_sessions = []
|
|
try:
|
|
if os.path.exists(db_path):
|
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
if row:
|
|
d = json.loads(row[0])
|
|
try:
|
|
cursor = conn.execute('SELECT data FROM sessions')
|
|
for r in cursor.fetchall():
|
|
db_sessions.append(json.loads(r[0]))
|
|
d['herdr_sessions'] = db_sessions
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
conn.close()
|
|
elif os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
pass
|
|
# Clean lone surrogates to prevent UnicodeEncodeError on stdout print
|
|
def clean_surrogates(obj):
|
|
if isinstance(obj, str):
|
|
return obj.encode('utf-8', errors='replace').decode('utf-8')
|
|
elif isinstance(obj, dict):
|
|
return {k: clean_surrogates(v) for k, v in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [clean_surrogates(x) for x in obj]
|
|
return obj
|
|
d = clean_surrogates(d)
|
|
print(json.dumps(d, ensure_ascii=False))
|
|
PYEOF
|
|
}
|
|
|
|
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
|
|
# all do `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
|
|
# returns the isolated herdr *session* name to use for this MAM session row, not
|
|
# a workspace id. Real isolation is `--session <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_workspace() {
|
|
local session_name="$1"
|
|
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
|
|
import sys, os, json
|
|
name = os.environ['SESSION_NAME']
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == name:
|
|
print(s.get('herdr_workspace') or s.get('herdr_server') or 'default')
|
|
sys.exit(0)
|
|
fallback = os.environ.get('HERDR_SERVER_NAME', '')
|
|
if not fallback or fallback == 'default':
|
|
pwd = os.path.abspath(os.getcwd())
|
|
fallback = os.path.basename(pwd)
|
|
print(fallback or 'default')
|
|
"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# derive_session_name <workspace> <agent>
|
|
#
|
|
# THE single source of truth for the herdr session name. Rule:
|
|
# slug = the two trailing path components of the absolute workspace,
|
|
# '_' -> '-', lowercased, joined with '-'
|
|
# name = "<slug>-creator-<agent>"
|
|
#
|
|
# Workspace root 기준 상대 해석. 예:
|
|
# $WORKSPACE_ROOT/landing_page/refer_landing_page + claude
|
|
# -> landing-page-refer-landing-page-creator-claude
|
|
#
|
|
# Decision (REVIEW P0-A): the actual workspace basename (refer_landing_page)
|
|
# IS included. The hand-written historical entry that dropped it
|
|
# (lab-landing-page-creator-claude) was the bug, not the convention.
|
|
# Every script and SKILL.md must use exactly this rule.
|
|
# ---------------------------------------------------------------------------
|
|
derive_session_name() {
|
|
local workspace="$1" agent="$2"
|
|
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 '%s-creator-%s' "$slug" "$agent"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|
|
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")
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
*=*)
|
|
local key="${1%%=*}"
|
|
_validate_env_key "$key" || return 1
|
|
envs+=("$1")
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
local mutation; mutation="$(cat)"
|
|
local pybin
|
|
pybin="$(_delegate_py_bin)"
|
|
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" "$pybin" - <<'PYEOF'
|
|
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3
|
|
from datetime import datetime, timezone
|
|
import yaml
|
|
|
|
yaml_path = os.environ['YAML_PATH']
|
|
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
|
|
|
def _validate(d):
|
|
if not isinstance(d, dict):
|
|
raise SystemExit("VALIDATE: top-level is not a mapping")
|
|
sessions = d.get('herdr_sessions', [])
|
|
if not isinstance(sessions, list):
|
|
raise SystemExit("VALIDATE: herdr_sessions is not a list")
|
|
valid = {'running', 'terminated', 'archived', 'stopped'}
|
|
for i, s in enumerate(sessions):
|
|
if not isinstance(s, dict):
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] not a mapping")
|
|
if not s.get('name') or not s.get('status'):
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] missing name/status")
|
|
if s.get('role') is not None and (not isinstance(s['role'], str) or not s['role'].strip()):
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} role must be a non-empty string")
|
|
if s['status'] not in valid:
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
|
if not isinstance(s.get('pane'), dict):
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} missing pane")
|
|
iso = s.get('isolation')
|
|
if iso is not None:
|
|
if not isinstance(iso, dict) or not iso.get('uuid') or not iso.get('root'):
|
|
raise SystemExit(f"VALIDATE: herdr_sessions[{i}] {s.get('name')!r} isolation block requires uuid/root")
|
|
|
|
def get_terminal_set(d):
|
|
return {s.get('name'): s.get('status') for s in d.get('herdr_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')}
|
|
|
|
def get_all_sessions_status(d):
|
|
import hashlib
|
|
res = {}
|
|
for s in d.get('herdr_sessions', []):
|
|
name = s.get('name')
|
|
ser = json.dumps(s, sort_keys=True)
|
|
res[name] = hashlib.sha256(ser.encode('utf-8')).hexdigest()
|
|
return res
|
|
|
|
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
|
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
|
|
for f in [db_path, db_path + '-wal', db_path + '-shm']:
|
|
if os.path.exists(f):
|
|
try:
|
|
os.chmod(f, 0o600)
|
|
except Exception:
|
|
pass
|
|
|
|
is_nfs = os.environ.get('MAM_IS_NFS') == 'true'
|
|
if is_nfs:
|
|
conn.execute('PRAGMA journal_mode=DELETE')
|
|
else:
|
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
|
|
try:
|
|
# Disable auto-commit by explicitly starting a transaction with BEGIN IMMEDIATE
|
|
# This prevents the read-modify-write lost update race condition.
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
conn.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, data TEXT)')
|
|
conn.execute('CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)')
|
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_pane_cwd ON sessions(pane_cwd)')
|
|
|
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
if row:
|
|
d = json.loads(row[0])
|
|
else:
|
|
# Seed from YAML
|
|
if os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
else:
|
|
d = {}
|
|
|
|
# Assemble d['herdr_sessions'] from sessions table if table contains data
|
|
db_sessions = []
|
|
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
|
|
for s_row in cursor.fetchall():
|
|
s_data = json.loads(s_row[3])
|
|
s_data['name'] = s_row[0]
|
|
s_data['status'] = s_row[1]
|
|
if 'pane' not in s_data:
|
|
s_data['pane'] = {}
|
|
s_data['pane']['cwd'] = s_row[2]
|
|
db_sessions.append(s_data)
|
|
|
|
if db_sessions:
|
|
d['herdr_sessions'] = db_sessions
|
|
elif 'herdr_sessions' not in d:
|
|
d['herdr_sessions'] = []
|
|
|
|
old_sessions = get_all_sessions_status(d)
|
|
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
|
|
|
|
# --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) ---
|
|
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
|
|
|
|
# Role immutability check
|
|
for s in d.get('herdr_sessions', []):
|
|
name = s.get('name')
|
|
if name in old_roles and s.get('role') != old_roles[name]:
|
|
raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}")
|
|
|
|
# ID Uniqueness Check (T2)
|
|
running_keys = ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']
|
|
id_to_session = {}
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('status') == 'running':
|
|
s_name = s.get('name')
|
|
for k in running_keys:
|
|
v = s.get(k)
|
|
if v:
|
|
if v in id_to_session and id_to_session[v] != s_name:
|
|
raise SystemExit(f"VALIDATE: Duplicate running conversation ID {v!r} detected between {id_to_session[v]!r} and {s_name!r}")
|
|
id_to_session[v] = s_name
|
|
|
|
_validate(d)
|
|
|
|
# Separate globals and sessions for normalization
|
|
d_state = {k: v for k, v in d.items() if k != 'herdr_sessions'}
|
|
conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),))
|
|
|
|
current_names = []
|
|
for s in d.get('herdr_sessions', []):
|
|
name = s.get('name')
|
|
status = s.get('status')
|
|
pane_cwd = (s.get('pane') or {}).get('cwd', '')
|
|
conn.execute('REPLACE INTO sessions (name, status, pane_cwd, data) VALUES (?, ?, ?, ?)',
|
|
(name, status, pane_cwd, json.dumps(s)))
|
|
current_names.append(name)
|
|
|
|
if current_names:
|
|
placeholders = ','.join('?' for _ in current_names)
|
|
conn.execute(f'DELETE FROM sessions WHERE name NOT IN ({placeholders})', current_names)
|
|
else:
|
|
conn.execute('DELETE FROM sessions')
|
|
|
|
new_sessions = get_all_sessions_status(d)
|
|
|
|
conn.commit()
|
|
|
|
# Write to YAML when sessions status or session list changes (e.g. create/stop/resume)
|
|
if new_sessions != old_sessions:
|
|
if os.path.exists(yaml_path):
|
|
try:
|
|
shutil.copy2(yaml_path, yaml_path + '.bak')
|
|
except Exception:
|
|
pass
|
|
dir_ = os.path.dirname(yaml_path) or '.'
|
|
fd, tmp = tempfile.mkstemp(dir=dir_, prefix='.agent-sessions.', suffix='.tmp')
|
|
try:
|
|
with os.fdopen(fd, 'w') as f:
|
|
yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False,
|
|
allow_unicode=True, width=4096)
|
|
os.replace(tmp, yaml_path)
|
|
except Exception:
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
raise
|
|
|
|
try:
|
|
conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
# H3: Re-apply chmod 0600 after close to cover newly created -wal / -shm files
|
|
try:
|
|
os.chmod(db_path, 0o600)
|
|
wal = db_path + '-wal'
|
|
if os.path.exists(wal): os.chmod(wal, 0o600)
|
|
shm = db_path + '-shm'
|
|
if os.path.exists(shm): os.chmod(shm, 0o600)
|
|
except Exception:
|
|
pass
|
|
PYEOF
|
|
}
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify_session_uuid <workspace> <agent> <uuid> [session_row_json] [mode]
|
|
# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise.
|
|
# ---------------------------------------------------------------------------
|
|
VERIFY_SESSION_PYTHON='
|
|
def workspace_key(path):
|
|
return path.replace("/", "-").replace("_", "-")
|
|
|
|
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
|
|
import os, json, sqlite3
|
|
home = home_dir or os.environ.get("HOME_DIR", "")
|
|
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
|
|
row = row or {}
|
|
epoch = row.get("herdr_session_epoch", 0)
|
|
cwd = row.get("pane", {}).get("cwd", "") or ws
|
|
|
|
if workspace_key(cwd) != workspace_key(ws):
|
|
return False
|
|
|
|
if agent == "claude":
|
|
base = c_dir
|
|
key = workspace_key(ws)
|
|
path = f"{base}/{key}/{uuid}.jsonl"
|
|
if not os.path.exists(path):
|
|
return False
|
|
if epoch and os.path.getmtime(path) < epoch:
|
|
return False
|
|
try:
|
|
with open(path) as f:
|
|
first = f.readline().strip()
|
|
if not first:
|
|
return False
|
|
payload = json.loads(first)
|
|
if payload.get("sessionId") != uuid:
|
|
return False
|
|
if payload.get("cwd") and payload.get("cwd") != cwd:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
elif agent == "agy":
|
|
base = f"{home}/.gemini/antigravity-cli/conversations"
|
|
path = f"{base}/{uuid}.db"
|
|
if not os.path.exists(path):
|
|
return False
|
|
if epoch and os.path.getmtime(path) < epoch:
|
|
return False
|
|
if mode == "discover":
|
|
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
|
cache_match = False
|
|
if os.path.exists(lc):
|
|
try:
|
|
with open(lc) as f:
|
|
lc_data = json.load(f)
|
|
cache_match = (lc_data.get(cwd) == uuid)
|
|
except Exception:
|
|
cache_match = False
|
|
if not cache_match:
|
|
if uuid in (row.get("_sibling_claimed_uuids") or []):
|
|
return False
|
|
try:
|
|
conn = sqlite3.connect(path)
|
|
r = conn.execute("SELECT count(*) FROM steps").fetchone()
|
|
conn.close()
|
|
if not r or r[0] < 1:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
elif agent == "hermes":
|
|
hdb = f"{home}/.hermes/state.db"
|
|
if not os.path.exists(hdb):
|
|
return False
|
|
if epoch and os.path.getmtime(hdb) < epoch:
|
|
return False
|
|
try:
|
|
conn = sqlite3.connect(hdb)
|
|
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone()
|
|
conn.close()
|
|
if not r:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
elif agent == "cline":
|
|
base = f"{home}/.cline/data/sessions"
|
|
path = f"{base}/{uuid}/{uuid}.json"
|
|
if not os.path.exists(path):
|
|
return False
|
|
if epoch and os.path.getmtime(path) < epoch:
|
|
return False
|
|
try:
|
|
with open(path) as f:
|
|
sdata = json.load(f)
|
|
if sdata.get("session_id") != uuid:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
return True
|
|
'
|
|
|
|
verify_session_uuid() {
|
|
local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}"
|
|
MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, json
|
|
exec(os.environ['MAM_VERIFY_PY'])
|
|
ws = os.environ['WS_ABS']
|
|
agent = os.environ['AGENT']
|
|
uuid = os.environ['UUID']
|
|
row_str = os.environ.get('ROW_JSON', '')
|
|
row = json.loads(row_str) if row_str else {}
|
|
mode = os.environ.get('MODE', 'discover')
|
|
if verify_session_uuid(ws, agent, uuid, row, mode=mode):
|
|
sys.exit(0)
|
|
sys.exit(1)
|
|
PYEOF
|
|
}
|
|
|
|
# verify_tui_viewport <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="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$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
|
|
|
|
case "$agent" in
|
|
claude)
|
|
if printf '%s\n' "$pane_content" | grep -q "$base"; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
;;
|
|
agy|hermes|cline)
|
|
if printf '%s\n' "$pane_content" | grep -q "$base"; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
;;
|
|
*)
|
|
return 2
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# find_workspace_uuid <workspace> <agent>
|
|
#
|
|
# Workspace-SCOPED resolution of the resume UUID (P0-C). It NEVER returns a
|
|
# global agent_identities id unless that id's project_cwd matches THIS
|
|
# workspace. Resolution order:
|
|
# 1) herdr_sessions[] row whose pane.cwd == this workspace -> per-row own id
|
|
# (claude_session_id_own / agy_conversation_id_own)
|
|
# 2) on-disk scan scoped to this workspace
|
|
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
|
|
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
|
|
# 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="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
|
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
|
import os, sys, json, glob, sqlite3
|
|
|
|
ws = os.environ['WS_ABS']
|
|
agent = os.environ['AGENT']
|
|
home = os.environ['HOME_DIR']
|
|
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
|
target = os.environ.get('TARGET_SESSION', '')
|
|
|
|
exec(os.environ['MAM_VERIFY_PY'])
|
|
|
|
OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own',
|
|
'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'}
|
|
|
|
def iso_root_of(s):
|
|
iso = s.get('isolation')
|
|
return iso.get('root') if isinstance(iso, dict) else None
|
|
|
|
# Ingest the merged state dictionary from stdin pipeline
|
|
try:
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
|
except Exception:
|
|
d = {}
|
|
|
|
running_ids = set()
|
|
for s_item in d.get('herdr_sessions', []):
|
|
if s_item.get('status') == 'running':
|
|
if target and s_item.get('name') == target:
|
|
continue
|
|
for k in ['claude_session_id_own', 'agy_conversation_id_own', 'hermes_conversation_id_own', 'cline_conversation_id_own']:
|
|
val = s_item.get(k)
|
|
if val:
|
|
running_ids.add(val)
|
|
|
|
def emit(u):
|
|
if u in running_ids:
|
|
return
|
|
print(u)
|
|
raise SystemExit(0)
|
|
|
|
# Fetch all sessions matching this workspace
|
|
sessions = []
|
|
for s in d.get('herdr_sessions', []):
|
|
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
|
sessions.append(s)
|
|
|
|
if target:
|
|
for s in sessions:
|
|
if s.get('name') != target:
|
|
continue
|
|
iso = iso_root_of(s)
|
|
cand = s.get(OWN_KEY.get(agent, ''), None)
|
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
emit(cand)
|
|
if iso:
|
|
key = workspace_key(ws)
|
|
if agent == 'claude':
|
|
for j in sorted(glob.glob(f"{iso}/projects/{key}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
|
cand = os.path.basename(j)[:-6]
|
|
if cand and verify_session_uuid(ws, agent, cand, s):
|
|
emit(cand)
|
|
elif agent == 'agy':
|
|
for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True):
|
|
cand = os.path.basename(j)[:-3]
|
|
if cand and verify_session_uuid(ws, agent, cand, s):
|
|
emit(cand)
|
|
elif agent == 'hermes':
|
|
hdb = f"{iso}/.hermes/state.db"
|
|
if os.path.exists(hdb):
|
|
try:
|
|
conn = sqlite3.connect(hdb)
|
|
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
|
conn.close()
|
|
if r:
|
|
cand = r[0]
|
|
if verify_session_uuid(ws, agent, cand, s):
|
|
emit(cand)
|
|
except Exception:
|
|
pass
|
|
elif agent == 'cline':
|
|
for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True):
|
|
fn = os.path.basename(folder)
|
|
if os.path.exists(f"{folder}/{fn}.json"):
|
|
if verify_session_uuid(ws, agent, fn, s):
|
|
emit(fn)
|
|
print('')
|
|
raise SystemExit(0)
|
|
|
|
for s in sessions:
|
|
name = s.get('name', '')
|
|
if agent == 'claude' and name.endswith('-creator-claude'):
|
|
cand = s.get('claude_session_id_own')
|
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
emit(cand)
|
|
if agent == 'agy' and name.endswith('-creator-agy'):
|
|
cand = s.get('agy_conversation_id_own')
|
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
emit(cand)
|
|
if agent == 'hermes' and name.endswith('-creator-hermes'):
|
|
cand = s.get('hermes_conversation_id_own')
|
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
emit(cand)
|
|
if agent == 'cline' and name.endswith('-creator-cline'):
|
|
cand = s.get('cline_conversation_id_own')
|
|
if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"):
|
|
emit(cand)
|
|
|
|
if agent == 'claude':
|
|
key = workspace_key(ws)
|
|
proj = f"{claude_project_dir}/{key}"
|
|
if os.path.isdir(proj):
|
|
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
|
cand = os.path.basename(j)[:-6]
|
|
if cand and verify_session_uuid(ws, agent, cand):
|
|
emit(cand)
|
|
elif agent == 'agy':
|
|
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
|
if os.path.exists(lc):
|
|
cand = None
|
|
try:
|
|
cand = json.load(open(lc)).get(ws)
|
|
except Exception:
|
|
cand = None
|
|
if cand and verify_session_uuid(ws, agent, cand):
|
|
emit(cand)
|
|
elif agent == 'hermes':
|
|
hdb = f"{home}/.hermes/state.db"
|
|
if os.path.exists(hdb):
|
|
cand = None
|
|
try:
|
|
conn = sqlite3.connect(hdb)
|
|
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
|
conn.close()
|
|
if r:
|
|
cand = r[0]
|
|
except Exception:
|
|
cand = None
|
|
if cand and verify_session_uuid(ws, agent, cand):
|
|
emit(cand)
|
|
elif agent == 'cline':
|
|
sessions_dir = f"{home}/.cline/data/sessions"
|
|
if os.path.isdir(sessions_dir):
|
|
candidates = []
|
|
for session_folder in glob.glob(f"{sessions_dir}/*"):
|
|
if os.path.isdir(session_folder):
|
|
folder_name = os.path.basename(session_folder)
|
|
json_file = f"{session_folder}/{folder_name}.json"
|
|
if os.path.exists(json_file):
|
|
candidates.append(json_file)
|
|
candidates.sort(key=os.path.getmtime, reverse=True)
|
|
for j in candidates:
|
|
cand = os.path.basename(j)[:-5]
|
|
if cand and verify_session_uuid(ws, agent, cand):
|
|
emit(cand)
|
|
|
|
ai = {}
|
|
db_path = f"{mam_dir}/agent-sessions.db" if 'mam_dir' in locals() else os.path.join(ws, ".mam", "agent-sessions.db")
|
|
yaml_path = f"{mam_dir}/agent-sessions.yaml" if 'mam_dir' in locals() else os.path.join(ws, ".mam", "agent-sessions.yaml")
|
|
try:
|
|
import yaml
|
|
if os.path.exists(db_path):
|
|
conn = sqlite3.connect(db_path, timeout=60.0)
|
|
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
|
if row:
|
|
ai = json.loads(row[0]).get('agent_identities', {})
|
|
conn.close()
|
|
elif os.path.exists(yaml_path):
|
|
with open(yaml_path) as f:
|
|
d = yaml.safe_load(f) or {}
|
|
ai = d.get('agent_identities', {})
|
|
except Exception as e:
|
|
print(f"WARN: tier-3 identity lookup failed: {e}", file=sys.stderr)
|
|
|
|
ai_agent = ai.get(agent) or {}
|
|
if ai_agent.get('project_cwd') == ws:
|
|
if agent == 'claude':
|
|
cand = ai_agent.get('session_id')
|
|
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
emit(cand)
|
|
elif agent == 'agy':
|
|
cand = ai_agent.get('conversation_id')
|
|
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
emit(cand)
|
|
elif agent == 'hermes':
|
|
cand = ai_agent.get('session_id') or ai.get('conversation_id')
|
|
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
emit(cand)
|
|
elif agent == 'cline':
|
|
cand = ai_agent.get('session_id') or ai.get('conversation_id')
|
|
if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"):
|
|
emit(cand)
|
|
|
|
print('')
|
|
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, 3-tier, race-free
|
|
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
|
|
# so recording its result into the row before kill guarantees tier-1 on the next
|
|
# resume. Pass session_name to scope resolution to that row (required for
|
|
# isolated sessions — see isolation block / T5). Always exits 0.
|
|
# ---------------------------------------------------------------------------
|
|
capture_conversation_id() {
|
|
local agent="$1" workdir="$2" session_name="${3:-}"
|
|
find_workspace_uuid "$workdir" "$agent" "$session_name"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session isolation — Universal Global Config (Option A, Job 536a6625)
|
|
#
|
|
# Config-home isolation (.mam/agent_homes/<uuid>/) was removed in favor of:
|
|
# 1. Universal Global Config: all agents read/write standard ~/.claude, ~/.gemini,
|
|
# ~/.hermes, ~/.cline user configuration and credential stores.
|
|
# 2. Process Isolation: each agent-workspace pair runs in its own herdr pane.
|
|
# 3. Conversation Isolation: session UUIDs discriminate conversation history.
|
|
#
|
|
# Stubbed isolation functions kept for backward compatibility:
|
|
# ---------------------------------------------------------------------------
|
|
provision_isolation() {
|
|
local agent="$1" root="$2"
|
|
printf ''
|
|
}
|
|
|
|
isolation_lever() {
|
|
case "$1" in
|
|
claude|agy|hermes|cline) echo "none" ;;
|
|
*) echo "" ;;
|
|
esac
|
|
}
|
|
|
|
isolation_env_prefix() {
|
|
:
|
|
}
|
|
|
|
isolation_cmd_args() {
|
|
:
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_already_stopped <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_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() {
|
|
local prompt="$1" agent="$2" session="$3"
|
|
local py_bin registry_py
|
|
py_bin="$(_delegate_py_bin)"
|
|
registry_py="$(_delegate_script registry.py)"
|
|
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
|
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
|
return 1
|
|
fi
|
|
"$py_bin" "$registry_py" register \
|
|
--prompt "$prompt" \
|
|
--agent "$agent" \
|
|
--agent-session "$session"
|
|
}
|
|
|
|
# delegate_publish_event <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() {
|
|
local job_id="$1" event="$2" detail="${3:-}"
|
|
[ -n "$job_id" ] || return 0
|
|
local py_bin pub
|
|
py_bin="$(_delegate_py_bin)"
|
|
pub="$(_delegate_script publish_event.py)"
|
|
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
|
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true
|
|
}
|
|
|
|
# start_watchdog <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() {
|
|
local job_id="$1"
|
|
local workdir="${2:-$PWD}"
|
|
local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
|
|
local log_file="$workdir/.mam/multi-agent-mux-monitor.log"
|
|
|
|
if [ ! -f "$monitor_script" ]; then
|
|
echo "ERROR: monitor script not found: $monitor_script" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Check if reconcile.sh --subscribe is already running on this workspace
|
|
local pid
|
|
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
|
|
|
|
if [ -z "$pid" ]; then
|
|
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
|
|
# and ensure it runs with $workdir as cwd to anchor relative log paths.
|
|
local orig_pwd="$PWD"
|
|
cd "$workdir" || return 1
|
|
nohup bash "$monitor_script" --subscribe --idle-timeout 0 >> "$log_file" 2>&1 &
|
|
pid=$!
|
|
cd "$orig_pwd" || return 1
|
|
fi
|
|
|
|
echo "$pid"
|
|
}
|
|
|
|
# wait_for_tui_ready <session_name> <agent>
|
|
# Waits up to 15 seconds for the agent's TUI to render its welcome screen.
|
|
wait_for_tui_ready() {
|
|
local sess="$1" agent="$2"
|
|
local local_herdr="herdr" i
|
|
if [ -n "${HERDR_SERVER_NAME:-}" ] && [ "$HERDR_SERVER_NAME" != "default" ]; then
|
|
local_herdr="herdr -L $HERDR_SERVER_NAME"
|
|
fi
|
|
|
|
for i in {1..30}; do
|
|
if _pane_dialog_open "$sess"; then
|
|
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
|
_sks_herdr send-keys -t "$sess" Enter || true
|
|
sleep 1
|
|
fi
|
|
sleep 1
|
|
continue
|
|
fi
|
|
local content
|
|
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
|
|
if [ -n "$content" ]; then
|
|
case "$agent" in
|
|
claude)
|
|
if echo "$content" | grep -E -q "$_MAM_READY_TOKENS_CLAUDE" 2>/dev/null; then
|
|
echo "✅ Claude TUI detected ready."
|
|
return 0
|
|
fi
|
|
;;
|
|
agy)
|
|
if echo "$content" | grep -q "Antigravity" 2>/dev/null; then
|
|
echo "✅ Antigravity TUI detected ready."
|
|
return 0
|
|
fi
|
|
;;
|
|
hermes)
|
|
if echo "$content" | grep -q "Hermes" 2>/dev/null; then
|
|
echo "✅ Hermes TUI detected ready."
|
|
return 0
|
|
fi
|
|
;;
|
|
cline)
|
|
if echo "$content" | grep -E -q "Cline|history|Chat|What can I do|slash commands" 2>/dev/null; then
|
|
echo "✅ Cline TUI detected ready."
|
|
return 0
|
|
fi
|
|
;;
|
|
esac
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
|
return 1
|
|
}
|
|
|
|
# inject_instructions <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() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; }
|
|
|
|
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
|
|
# blank rows; window on non-blank lines or top-anchored dialogs are invisible.
|
|
_pane_tail() { _pane_capture "$1" | grep -v '^[[:space:]]*$' | tail -n "${2:-20}"; }
|
|
|
|
# _wait_session_gone <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]
|
|
# Renderer settled = two consecutive identical non-empty captures.
|
|
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
|
_pane_quiescent() {
|
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
|
for ((i = 0; i < tries; i++)); do
|
|
cur=$(_pane_capture "$sess")
|
|
[ -n "$cur" ] && [ "$cur" = "$prev" ] && return 0
|
|
prev="$cur"
|
|
sleep "$interval"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust /
|
|
# permission / OAuth / list-selection), checked in the bottom 20 pane lines
|
|
# only (dialogs render near the input area; conversation text above must not
|
|
# trigger this). Tokens must NOT appear on normal idle prompt screens.
|
|
_pane_dialog_open() {
|
|
_pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS"
|
|
}
|
|
|
|
# send_keys_safe <sess> <text> [job_id]
|
|
# 1. Wait for renderer quiescence (RC-A).
|
|
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
|
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
|
# Escape per poll and re-check. NEVER a blind Enter.
|
|
# 3. Paste via unique buffer; verify the text landed (marker visible).
|
|
# 4. Submit C-m; verify submission (marker left the input area AND the pane
|
|
# changed); retry up to 3 times.
|
|
send_keys_safe() {
|
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
|
local marker pre_submit deadline try
|
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
|
# the properly-decoded rendered pane text) of the last non-empty line.
|
|
marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')")
|
|
# Whitespace-normalized copy for matching: some TUIs (e.g. cline's own
|
|
# message rendering) don't just soft-wrap with a bare newline — they add a
|
|
# leading-space "hanging indent" on the continuation line too, so removing
|
|
# only '\n' still leaves an extra space that breaks an exact literal match.
|
|
# Matching with all whitespace collapsed out sidesteps wrap formatting
|
|
# entirely, whatever shape it takes.
|
|
local marker_norm
|
|
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
|
|
|
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
|
|
|
|
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
|
while _pane_dialog_open "$sess"; do
|
|
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
|
_sks_herdr send-keys -t "$sess" Escape
|
|
sleep 1
|
|
fi
|
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
|
return 2
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
_sks_herdr set-buffer -b "sks_$job_id" "$text"
|
|
_sks_herdr paste-buffer -b "sks_$job_id" -t "$sess"
|
|
_sks_herdr delete-buffer -b "sks_$job_id" 2>/dev/null || true
|
|
if [[ "$sess" =~ "agy" ]]; then
|
|
_sks_herdr send-keys -t "$sess" C-m
|
|
return 0
|
|
fi
|
|
local was_popup=0
|
|
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]]; then
|
|
# Skip strict paste check due to scrollout false-positives, proceed to C-m loop
|
|
true
|
|
else
|
|
sleep 0.5
|
|
local pane_content
|
|
pane_content=$(_pane_capture "$sess")
|
|
if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then
|
|
was_popup=1
|
|
# Strip ALL whitespace before matching — the rendered pane soft-wraps long
|
|
# lines at the terminal width (and some TUIs add indentation on the
|
|
# continuation line too), which can split/reformat the marker across two
|
|
# visual lines and make a literal grep miss it even though the paste landed.
|
|
elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then
|
|
echo "send_keys_safe: paste not visible ($sess)" >&2
|
|
return 3
|
|
fi
|
|
fi
|
|
|
|
for try in 1 2 3; do
|
|
pre_submit=$(_pane_capture "$sess")
|
|
_sks_herdr send-keys -t "$sess" C-m
|
|
sleep "$try"
|
|
local cur_content
|
|
cur_content=$(_pane_capture "$sess")
|
|
# Hardened Submission Checks
|
|
if printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing…|[A-Za-z]+ing\.\.\.|esc to interrupt"; then
|
|
return 0
|
|
fi
|
|
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
elif [ "$was_popup" = "1" ] && \
|
|
! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \
|
|
[ "$cur_content" != "$pre_submit" ]; then
|
|
return 0
|
|
fi
|
|
done
|
|
echo "send_keys_safe: Enter not accepted after 3 tries ($sess)" >&2
|
|
return 4
|
|
}
|
|
|
|
# handle_startup_dialogs <sess> [timeout_sec=20]
|
|
# Post-start/resume dialog policy for claude: accept the trust / bypass
|
|
# dialogs ONLY when their signature is positively on screen; return as soon
|
|
# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence.
|
|
handle_startup_dialogs() {
|
|
local sess="$1" timeout="${2:-20}" waited=0 pane
|
|
while [ "$waited" -lt "$timeout" ]; do
|
|
pane=$(_pane_tail "$sess" 20)
|
|
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
|
|
_sks_herdr send-keys -t "$sess" Enter
|
|
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
|
|
_sks_herdr send-keys -t "$sess" Down
|
|
sleep 0.3
|
|
_sks_herdr send-keys -t "$sess" Enter
|
|
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
|
|
_sks_herdr send-keys -t "$sess" Enter
|
|
elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
waited=$((waited + 2))
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Auto-initialize the herdr translation shim wrapper on library source
|
|
_init_herdr_isolation
|