Files
multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh
T
GodopuandClaude Fable 5.1 7844ae2c5f fix(create): one pane claim at a time per herdr server; a create keeps the pane the monitor registered for it
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>
2026-10-11 06:05:52 +00:00

772 lines
36 KiB
Bash
Executable File

#!/usr/bin/env bash
# create_session.sh — multi-agent-mux-create 의 부속 스크립트
# Usage:
# bash create_session.sh --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex> --role <role> [--session <name>] [--herdr-session <name>] [--wrapper]
#
# 동작:
# 1) preflight: herdr/claude/agy 가용성, workspace 존재
# 2) herdr 세션 이름 결정 (--session 없으면 자동)
# 3) herdr 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
# 4) pane 메타 캡처 (pid, cmd, cwd)
# 5) agent-sessions.yaml 에 herdr_sessions[] 엔트리 append
# 6) 검증 출력
#
# Exit codes:
# 0 = success
# 1 = preflight failure
# 2 = invalid args
# 3 = herdr session already exists (use multi-agent-mux-resume or delete first)
# 4 = agent-sessions.yaml append failure
# 42 = agent TUI never became ready and the process is gone
# 43 = rollback could not kill the herdr session; it is still running (#23)
# 44 = the onboarding job ended with error (or was cancelled); the session is kept (#50)
# 45 = the onboarding job did not end within MAM_ONBOARD_WAIT_S; the session is kept (#50)
set -euo pipefail
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_lib_sh="$(cd "$_script_dir/../.." && pwd)/lib.sh"
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$_lib_sh"
usage() {
cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex> --role <role> [options]
Options:
--workspace PATH project directory (required)
--agent AGENT claude | agy | hermes | grok | opencode | codex (required)
--role ROLE assigned role (required)
--session NAME herdr session name (default: derived from workspace)
--wrapper force use of ~/.local/bin/<session> wrapper even if not present
--dry-run print commands without executing
--herdr-session NAME specify isolated herdr session name (alias: --herdr-server)
--herdr-server NAME specify isolated herdr session name (legacy alias)
--herdr-workspace NAME workspace label recorded in the registry
(flag > \$HERDR_WORKSPACE > workspace slug without mam-).
A label only — it never selects a herdr socket;
use --herdr-session for that.
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
--onboard automatically submit a project alignment/orientation job to the new agent
--no-auto-trust do not answer a start-up directory-trust dialog (default: answer it)
--no-onboard disable automatic onboarding job submission
--no-isolate legacy flag (no-op; all sessions use global configuration)
--isolate legacy flag (no-op; all sessions use global configuration)
-h, --help this help
EOF
}
WORKSPACE=""
AGENT=""
ROLE=""
SESSION_NAME=""
USE_WRAPPER=0
DRY_RUN=0
HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=1
# Issue #24: answer a start-up directory-trust dialog for the --workspace the
# operator named. --no-auto-trust leaves those unanswered (notices are always
# answered); codex in particular loads project-local config, hooks and exec
# policy once trusted. MAM_AUTO_TRUST=0 in the environment is the same switch
# (the skill docs say so); an explicit --auto-trust/--no-auto-trust wins over it
# (merge review, codex probe: the export below used to overwrite the caller's 0).
AUTO_TRUST="${MAM_AUTO_TRUST:-1}"
while [ $# -gt 0 ]; do
case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--role) ROLE="$2"; shift 2 ;;
--session|--name) SESSION_NAME="$2"; shift 2 ;;
--wrapper) USE_WRAPPER=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--no-onboard) ONBOARD=0; shift ;;
--auto-trust) AUTO_TRUST=1; shift ;;
--no-auto-trust) AUTO_TRUST=0; shift ;;
--isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;; # legacy compatibility
--no-isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
export MAM_AUTO_TRUST="$AUTO_TRUST"
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
fi
# Preflight
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; usage; exit 2; }
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
case "$AGENT" in
claude|agy|hermes|grok|opencode|codex) ;;
*) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex, got: $AGENT" >&2; exit 2 ;;
esac
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
# B-3: `command -v herdr` matches lib.sh's herdr() function and `type -P herdr`
# matches the .mam/shim wrapper, so both pass with no herdr installed.
has_real_herdr || { echo "ERROR: herdr not installed" >&2; exit 1; }
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
# Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes)
if [ "$AGENT" = "claude" ]; then
if ! claude auth status 2>/dev/null | grep -q '"loggedIn":\s*true'; then
echo "ERROR: claude not logged in. Run 'claude auth login' first." >&2
exit 1
fi
elif [ "$AGENT" = "agy" ]; then
# Fast, non-blocking check: if token or credentials exist on disk, assume authenticated to prevent keyring hang
if [ -f "$HOME/.gemini/oauth_creds.json" ] || [ -f "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" ]; then
true
elif ! agy models >/dev/null 2>&1; then
echo "ERROR: agy is not authenticated. Please log in first." >&2
exit 1
fi
elif [ "$AGENT" = "hermes" ]; then
if ! hermes status >/dev/null 2>&1; then
echo "ERROR: hermes is not functional. Run 'hermes setup' first." >&2
exit 1
fi
elif [ "$AGENT" = "grok" ]; then
if [ -f "$HOME/.grok/auth.json" ] || [ -n "$XAI_API_KEY" ]; then
true
elif ! grok --version >/dev/null 2>&1; then
echo "ERROR: grok is not functional or configured." >&2
exit 1
fi
elif [ "$AGENT" = "opencode" ]; then
if [ -f "$HOME/.local/share/opencode/auth.json" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${GROQ_API_KEY:-}" ]; then
true
elif ! opencode --version >/dev/null 2>&1; then
echo "ERROR: opencode is not functional or configured." >&2
exit 1
fi
elif [ "$AGENT" = "codex" ]; then
codex_home="${CODEX_HOME:-$HOME/.codex}"
if [ -f "$codex_home/auth.json" ] || [ -n "${OPENAI_API_KEY:-}" ]; then
true
elif ! codex --version >/dev/null 2>&1; then
echo "ERROR: codex is not functional or configured." >&2
exit 1
fi
fi
# 세션 이름 — lib.sh::derive_session_name 이 단일 소스 (P0-A)
if [ -z "$SESSION_NAME" ]; then
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT" "$ROLE")"
fi
# 이미 살아있으면 실패
if _herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
exit 3
fi
# Config-home isolation was removed in favor of global config + process/UUID isolation.
# herdr 세션 띄우기
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
ws_slug="$(derive_workspace_slug "$WORKSPACE")"
# 플래그 > 환경변수 > 워크스페이스 슬러그 (C-3: HERDR_SESSION_NAME 과 대칭).
# D5: resolve_herdr_workspace 를 쓰지 않는다 — 동명 terminated 행 위에 재생성할 때
# 낡은 pane.cwd 에서 파생된 라벨을 물려받기 때문 (create 는 사실을 세우는 쪽).
export MAM_WS_LABEL="${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}"
if [ -z "$HERDR_SERVER_OPT" ]; then
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
export HERDR_SESSION_NAME="$ws_slug"
fi
fi
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
RESOLVED_BIN="$AGENT"
if command -v "$AGENT" >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v "$AGENT")"
fi
# On macOS, clear quarantine attribute for the agent binary to prevent Gatekeeper hangs
if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
SESSION_UUID=""
if [ "$AGENT" = "claude" ] || [ "$AGENT" = "grok" ]; then
SESSION_UUID="$(mam_gen_uuid)"
fi
# Retrieve agent facts once
eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$AGENT" 2>/dev/null || true)"
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "$SESSION_UUID" "${USE_WRAPPER:-0}" 2>/dev/null || true)"
if [ -z "$CMD_FULL" ]; then
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
grok) CMD_FULL="${RESOLVED_BIN} --permission-mode bypassPermissions" ;;
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
esac
fi
# Issue #21 (item 3): the control-plane binding. Persisted on the row always;
# forwarded to the pane (leading K=V tokens that lib.sh's new-session turns
# into --env) only when it differs from what --workspace alone implies.
# lib.sh splits those flags unquoted (and, with env flags, never reuses an
# idle pane - `agent start --` would pass them to the agent as arguments), so a
# binding with whitespace that would have to be forwarded is refused.
# A binding that cannot be computed is not swallowed (Issue #21 phase 2, A12):
# for a worktree (--workspace != control root) that would silently put the
# session on the wrong control plane, so refuse; for the control root itself
# warn and record the row without control_plane.
cp_rc=0
CP_JSON="$(mam_control_plane_json "$WORKSPACE")" || cp_rc=$?
if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then
if [ "$(mam_abs_workspace "$WORKSPACE")" != "$(mam_abs_workspace "$WORKSPACE_ROOT")" ]; then
echo "ERROR: cannot compute the control-plane binding (rc=$cp_rc) for --workspace '$WORKSPACE' (control root '$WORKSPACE_ROOT'); refusing to start a session that would use the wrong control plane" >&2
exit 2
fi
echo "WARNING: control-plane binding unavailable (rc=$cp_rc); the row is recorded without control_plane" >&2
CP_JSON=""
fi
CP_PREFIX=""
if [ -n "$CP_JSON" ]; then
cp_rc=0
CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$?
if [ "$cp_rc" -eq 3 ]; then
echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2
exit 2
fi
fi
spawn() {
if [ -z "$HERDR_SERVER_OPT" ]; then
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
export HERDR_SESSION_NAME="$ws_slug"
fi
fi
case "$AGENT" in
claude)
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
SESSION_UUID=""
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")"
[ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2
nohup "$WRAPPER" >/dev/null 2>&1 &
disown
else
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
fi
;;
agy|hermes|grok|codex)
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
;;
opencode)
if [ -z "${OPENCODE_PERMISSION:-}" ]; then
export OPENCODE_PERMISSION='{"*":"allow"}'
fi
HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
;;
*) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex, got: $AGENT" >&2; exit 2 ;;
esac
}
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})"
exit 0
fi
HERDR_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
spawn
# _mark_row_rolled_back <status> <note>
# ISSUE-23: the YAML row and the job registration are both written before the
# prompt is injected, so a rollback that only kills the pane leaves a row that
# says `running` pointing at a failed job - which is what status.sh keeps
# reporting (symptom 5). Mark the row instead of deleting it: a deleted row
# takes the evidence with it.
#
# <status> stays inside the schema's vocabulary (running/terminated/archived/
# stopped - atomic_yaml.py rejects anything else): `terminated` when the
# session is really gone, and `running` when the kill failed, because it IS
# still running and saying otherwise would be the same lie in reverse. What
# makes the failed case legible is the note, the cleared job id and exit 43.
_mark_row_rolled_back() {
local status="$1" note="$2"
[ "${ROW_APPENDED:-0}" = "1" ] || return 0
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" ROLLBACK_STATUS="$status" ROLLBACK_NOTE="$note" <<'PYEOF' || true
name = os.environ['SESSION_NAME']
status = os.environ['ROLLBACK_STATUS']
note = os.environ['ROLLBACK_NOTE']
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
s['status'] = status
s['last_visible_status'] = note
s['delegate_job_id'] = None
break
PYEOF
}
# _mark_row_note <note>
# Issue #50: last_visible_status only. The row stays `running` with its
# delegate_job_id: the session is alive and the job record is the evidence.
_mark_row_note() {
local note="$1"
[ "${ROW_APPENDED:-0}" = "1" ] || return 0
atomic_dump_yaml "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" ROW_NOTE="$note" <<'PYEOF' || true
name = os.environ['SESSION_NAME']
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
s['last_visible_status'] = os.environ['ROW_NOTE']
break
PYEOF
}
# Trap for rolling back/cleaning up herdr session if script exits due to error
cleanup_herdr_on_error() {
local exit_code=$?
[ $exit_code -eq 0 ] && return 0
echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2
# ISSUE-23: the kill result used to be discarded (`2>/dev/null || true`), so
# a kill that closed nothing still printed "rolled back" while the session
# kept running. Check it, then confirm with has-session.
local kill_rc=0
_herdr kill-session -t "$SESSION_NAME" || kill_rc=$?
local alive=0
_herdr has-session -t "$SESSION_NAME" >/dev/null 2>&1 && alive=1
# Release the onboarding job: `cancelled` is a terminal transition the
# registry knows, and it says what happened - the job was never really
# dispatched. Best effort; a failure here must not stop the rollback.
# `completed`/`error`/`cancelled` are terminal and published exactly once, so
# a job this run already closed is left alone - a second one is refused by
# the registry anyway ("conflicting cancelled after terminal error").
if [ -n "${DELEGATE_JOB_ID:-}" ] && [ "${JOB_TERMINALIZED:-0}" != "1" ]; then
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true
JOB_TERMINALIZED=1
fi
if [ "$alive" = "1" ]; then
_mark_row_rolled_back "running" "rollback-failed"
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2
exit 43
fi
_mark_row_rolled_back "terminated" "rolled-back"
}
trap cleanup_herdr_on_error EXIT
if [ -z "$HERDR_SERVER_OPT" ]; then
RESOLVED_SERVER="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
export HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-$RESOLVED_SERVER}"
fi
# TUI 준비 대기
TUI_READY_RC=0
wait_for_tui_ready "$SESSION_NAME" "$AGENT" || TUI_READY_RC=$?
LAST_VISIBLE_STATUS_OVERRIDE=""
if [ "$TUI_READY_RC" -ne 0 ]; then
# 1. Diagnostic dump
DIAG_DIR="${WORKSPACE:-$PWD}/.mam/diagnostics"
mkdir -p "$DIAG_DIR" 2>/dev/null || true
DIAG_FILE="$DIAG_DIR/${SESSION_NAME}-$(date +%s).txt"
{
echo "=== MAM Diagnostic Dump ==="
echo "Date: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "MAM Version: ${MAM_VERSION:-unknown}"
echo "Session: $SESSION_NAME"
echo "Agent: $AGENT"
echo "ReturnCode: $TUI_READY_RC"
echo "--- Pane Raw Capture ---"
_pane_capture "$SESSION_NAME" 2>/dev/null || true
} > "$DIAG_FILE" 2>/dev/null || true
if [ ! -f "$DIAG_FILE" ]; then
echo "WARNING: Failed to write diagnostic dump to $DIAG_FILE" >&2
fi
# 2. Check pane PID liveness
PANE_PID_CHECK=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
ALIVE=false
if [ -n "$PANE_PID_CHECK" ] && [[ "$PANE_PID_CHECK" =~ ^[0-9]+$ ]]; then
if kill -0 "$PANE_PID_CHECK" 2>/dev/null; then
ALIVE=true
fi
fi
if [ "$ALIVE" = true ] && [ "${MAM_KILL_ON_READY_TIMEOUT:-0}" != "1" ]; then
echo "WARNING: agent TUI never became ready within timeout, but process (PID $PANE_PID_CHECK) is alive. Preserving session for diagnosis." >&2
LAST_VISIBLE_STATUS_OVERRIDE="tui-ready-timeout"
SUBMIT_JOB_PROMPT="" # disable instruction injection on unready session
else
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2
exit 42
fi
fi
# Issue #24: every agent, not only claude. The signatures and accept keys come
# from the agent adapter, so an agy/grok/codex trust prompt is answered here
# too instead of sitting until the readiness wait gives up.
if [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
handle_startup_dialogs "$SESSION_NAME" 15 "$AGENT"
fi
# pane 메타 캡처
PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
# 시작 명령
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
# herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim).
START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
# If --onboard is specified, automatically build the onboarding prompt (if session is ready)
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks:
1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules.
2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository.
3. Read .mam/agent-sessions.yaml to see other running agent sessions, their roles, and confirm your own assigned role: $ROLE.
4. Once you have fully comprehended the project state, publish a completed event (using publish_event.py) with the detail 'Onboarding complete; aligned with role $ROLE'."
fi
# agent-sessions.yaml 에 append
DELEGATE_JOB_ID=""
if [ -n "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT=""
fi
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
if [ -z "$delegate_agent" ]; then
case "$AGENT" in
claude) delegate_agent="claude-code" ;;
hermes) delegate_agent="hermes-agent" ;;
agy) delegate_agent="antigravity-cli" ;;
grok) delegate_agent="grok-build" ;;
opencode) delegate_agent="opencode-cli" ;;
codex) delegate_agent="codex-cli" ;;
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
esac
fi
agent_session="herdr:$SESSION_NAME"
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
echo "Submitted delegated job: $DELEGATE_JOB_ID"
DELEGATE_JOB_TOKEN=""
DELEGATE_JOB_ATTEMPT=""
DELEGATE_JOB_ACK_S=""
if [ -n "$DELEGATE_JOB_ID" ]; then
local_info=$(python3 -c "
import sys, json, os
jid = '$DELEGATE_JOB_ID'
reg_dir = os.environ.get('MAM_JOBS_DIR') or (os.path.join(os.environ['WORKSPACE_ROOT'], '.mam', 'jobs') if os.environ.get('WORKSPACE_ROOT') else '.mam/jobs')
p = os.path.join(reg_dir, f'{jid}.json')
if os.path.exists(p):
try:
with open(p) as f:
d = json.load(f)
tok = d.get('dispatch_token', '')
att = d.get('attempt_id', d.get('iteration', 1))
ack = d.get('ack_timeout_sec', 180)
print(f'{tok} {att} {ack}')
except Exception:
pass
" 2>/dev/null || true)
if [ -n "$local_info" ]; then
DELEGATE_JOB_TOKEN=$(echo "$local_info" | awk '{print $1}')
DELEGATE_JOB_ATTEMPT=$(echo "$local_info" | awk '{print $2}')
DELEGATE_JOB_ACK_S=$(echo "$local_info" | awk '{print $3}')
fi
fi
fi
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
mkdir -p "$(dirname "$AGENT_SESSIONS_YAML")"
echo "herdr_sessions: []" > "$AGENT_SESSIONS_YAML"
fi
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
# 모든 값은 환경변수로 전달 — heredoc interpolation 없음 (P1-B).
# 자식 pid 는 bash 에서 pgrep 으로 미리 구함 (P2: 도구명 필터).
CHILD_PID=0
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ]; } && [ -n "$PANE_PID" ]; then
CHILD_PID=$(pgrep -P "$PANE_PID" -x "$AGENT" 2>/dev/null | head -1 || true)
CHILD_PID="${CHILD_PID:-0}"
fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
MAM_WS_LABEL="$MAM_WS_LABEL" \
SESSION_UUID="$SESSION_UUID" \
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF'
name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
role = os.environ['ROLE']
pid = os.environ.get('PANE_PID', '')
epoch = os.environ.get('HERDR_EPOCH', '')
server_name = os.environ.get('HERDR_SESSION_NAME', 'default')
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
sessions = d.setdefault('herdr_sessions', [])
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
# Issue #50 follow-up: a reconcile pass (another create's end-of-run `--once`,
# the subscriber's cycle) can adopt the pane this create started moments ago,
# before this row lands. A running row of this name that the monitor
# registered, with no job, for a pane created after this create began, is
# this create's own pane - herdr keeps live names unique per server and the
# preflight refused an older session of this name - so it is replaced, not
# refused as a second live session.
def _adopted_own(s):
return (s.get('last_visible_note') == 'auto-registered by monitor' and not s.get('delegate_job_id')
and epoch.isdigit() and int(s.get('herdr_session_epoch') or 0) >= int(epoch) - 1)
if running_same and all(_adopted_own(s) for s in running_same):
print(f"NOTE: the monitor registered {name} while it was being created; that row is replaced by this one", flush=True)
running_same = []
old_roles.pop(name, None) # its role was read off the name; --role is the fact (atomic_yaml's role guard)
if running_same:
print(f"ERROR: {name} already running in agent-sessions.yaml", flush=True)
raise SystemExit(4)
sessions[:] = [s for s in sessions if s.get('name') != name]
entry = {
'name': name,
'status': 'running',
'role': role,
'herdr_session_created_at': os.environ['NOW_ISO'],
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
'herdr_session': server_name,
'herdr_server': server_name,
'herdr_workspace': os.environ.get('MAM_WS_LABEL', ''),
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
'pane': {
'index': 0,
'pid': int(pid) if pid.isdigit() else 0,
'cmd': agent,
'cmd_full': os.environ['CMD_FULL'],
'cwd': os.environ['PANE_CWD'],
},
'start_command': os.environ['START_CMD'],
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
}
if agent == 'claude':
entry['tui'] = {
'model': '(unknown — capture after first message)',
'provider': 'anthropic',
'plan': '(unknown)',
'account': '(unknown — read from claude auth status)',
'version': '(unknown — read from TUI)',
}
assigned = os.environ.get('SESSION_UUID', '') or None
entry['claude_session_id_own'] = assigned
entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
elif agent == 'agy':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['agy_conversation_id_own'] = None
entry['mcp_attachments'] = [
{
'name': 'stitch',
'transport': 'mcp-remote',
'endpoint': 'https://stitch.googleapis.com/mcp'
}
]
entry['last_visible_status'] = "unverified"
elif agent == 'hermes':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['hermes_conversation_id_own'] = None
entry['last_visible_status'] = "unverified"
elif agent == 'grok':
assigned = os.environ.get('SESSION_UUID', '') or None
entry['grok_session_id_own'] = assigned
entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
elif agent == 'opencode':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['opencode_session_id_own'] = None
entry['session_id_source'] = 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "unverified"
elif agent == 'codex':
cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0
entry['codex_session_id_own'] = None
entry['session_id_source'] = 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "unverified"
last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '')
if last_vis_override:
entry['last_visible_status'] = last_vis_override
# Issue #21: the control-plane binding this row was created against.
_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '')
if _cp_raw:
try:
import json as _json
_cp = _json.loads(_cp_raw)
entry['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')}
except Exception:
pass
sessions.append(entry)
snap = d.setdefault('snapshot', {})
snap['taken_at'] = os.environ['NOW_ISO']
snap['cwd'] = os.environ['PANE_CWD']
print(f"appended: {name}", flush=True)
PYEOF
ROW_APPENDED=1
echo
echo "=== created ==="
echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
if [ -n "$DELEGATE_JOB_ID" ]; then
echo "delegate job: $DELEGATE_JOB_ID"
# The publish command the agent will run: the same shape the delegate-job
# wrapper hands its agents, from the one builder in lib.sh. The dispatch
# token and attempt id are what make the agent's own `started` name THIS
# dispatch (trial-2 finding: without them the token-checking ack below could
# never be satisfied by a real agent, and every onboarding sat out 180 s).
pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"
# Issue #36: the task goes into a brief in the job's directory and the pane
# gets only its path, as the delegate-job wrapper does. The injected text is
# one argv string to herdr (capped at 131,072 bytes), so the whole task
# plus this preamble could not reach the agent.
task_reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
[[ "$task_reg_dir" == /* ]] || task_reg_dir="$PWD/$task_reg_dir"
task_brief="$task_reg_dir/$DELEGATE_JOB_ID/brief.md"
if ! { mkdir -p "${task_brief%/*}" && printf '%s' "$SUBMIT_JOB_PROMPT" > "$task_brief"; }; then
echo "ERROR: cannot write the task brief $task_brief" >&2
delegate_publish_event "$DELEGATE_JOB_ID" error "task brief could not be written" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
JOB_TERMINALIZED=1
exit 1
fi
instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation).
On start run: $pub --event started.
On progress (optional): $pub --event progress --detail '<short status>'.
On success run: $pub --event completed --detail '<one-line summary>'.
On failure run: $pub --event error --detail '<one-line reason>'.
Task: read the complete task in $task_brief and follow it.
If you delegate any part of it with multi-agent-mux-delegate-job submit, add --parent-job $DELEGATE_JOB_ID so the orchestrator waits for it."
# Inject instructions into the herdr pane
rc=0
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
# ISSUE-23: rc 4 is "Enter was not seen to be accepted within the send_keys
# window", which is where a TUI that is still finishing its start-up lands.
# It is a warning now; the agent's own `started` decides. rc 1/2/3 are still
# failures: they say the pane never quiesced, a dialog was blocking, or the
# text never appeared - none of which a later `started` can repair.
if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then
delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
JOB_TERMINALIZED=1
exit 1
fi
if [ "$rc" -eq 4 ]; then
echo "WARNING: Enter was not observed to be accepted for '$SESSION_NAME' (rc=4); waiting for the agent's own started event instead." >&2
fi
# ISSUE-23: this script no longer publishes `started` on the agent's behalf.
# It used to, which made the registry show a started job even when the agent
# never saw the prompt, and left any started-based verdict meaningless.
#
# How long to wait for the handshake: MAM_ONBOARD_ACK_S, else the wrapper's
# MAM_INJECT_ACK_GRACE_S, else the job's own ack timeout. The create-specific
# variable comes first so this wait can be shortened - in a sandbox with no
# real agent, for instance - without also retuning the delegate-job
# wrapper's injection ack, which is a different wait with its own tests.
ONBOARD_ACK_S="${MAM_ONBOARD_ACK_S:-${MAM_INJECT_ACK_GRACE_S:-${DELEGATE_JOB_ACK_S:-180}}}"
if ! delegate_wait_started "$DELEGATE_JOB_ID" "$ONBOARD_ACK_S" "$DELEGATE_JOB_TOKEN"; then
# The job failed, the session did not: the pane is up and the row is valid,
# only the handshake never landed. Killing a live agent over a slow or
# silent onboarding would destroy more than it protects, and it would
# contradict this script's own rule for the same situation one step
# earlier - a TUI that never signalled ready is preserved for diagnosis,
# not rolled back. So: mark the job, warn loudly, keep the session.
# MULTI_AGENT_RULES §6 has the GM gate on this job's `completed` anyway,
# so an unacknowledged onboarding already blocks real work.
# A job that is already terminal is not marked again: `error` over a
# `completed` is refused by the registry anyway (trial-2 log: "conflicting
# error after terminal completed"), and a job the agent has finished is
# not an undelivered one - only the evidence was missing.
ONBOARD_JOB_ST="$(delegate_job_status "$DELEGATE_JOB_ID")"
case "$ONBOARD_JOB_ST" in
completed|error|cancelled)
echo "WARNING: no started event naming this dispatch for '$SESSION_NAME' within ${ONBOARD_ACK_S}s, but job $DELEGATE_JOB_ID is already '$ONBOARD_JOB_ST'; leaving it as it is." >&2
;;
*)
delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2
echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2
;;
esac
JOB_TERMINALIZED=1
fi
# Issue #50: the handshake only said the agent saw the prompt. Wait for the
# onboarding itself to end; its result is this script's exit code, so the
# orchestrator learns it from create (MULTI_AGENT_RULES §6) instead of
# guessing from herdr's agent_status. A job marked `error` just above is
# terminal and returns at once.
ONBOARD_WAIT_S=$(mam_int_knob MAM_ONBOARD_WAIT_S 600 1)
ONBOARD_T0=$(date +%s)
ONBOARD_STATUS="$(delegate_wait_terminal "$DELEGATE_JOB_ID" "$ONBOARD_WAIT_S")"
ONBOARD_STATUS="${ONBOARD_STATUS:-missing}"
# Trigger immediate priority reconcile cycle asynchronously
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
# (unreviewed) copy in a worktree --workspace; identical paths by default.
(bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT")
echo "watchdog PID: $WD_PID"
case "$ONBOARD_STATUS" in
completed)
echo "onboarding completed: job $DELEGATE_JOB_ID ($(( $(date +%s) - ONBOARD_T0 ))s)"
;;
*)
# The session is kept, as for a missing handshake above: the row says
# what happened and the exit code tells the caller. The trap is cleared
# first - a non-zero exit must not roll a live session back.
_mark_row_note "onboarding-$ONBOARD_STATUS"
echo "ERROR: onboarding $ONBOARD_STATUS for '$SESSION_NAME': job $DELEGATE_JOB_ID; the session is kept" >&2
trap - EXIT
case "$(delegate_onboard_exit "$ONBOARD_STATUS")" in
44) exit 44 ;;
*) exit 45 ;;
esac
;;
esac
fi
# Clear cleanup trap as registration was fully successful
trap - EXIT
echo "agent-sessions.yaml updated"
echo
echo "Attach: herdr session attach $SESSION_NAME"
echo "Delete: use multi-agent-mux-stop skill"
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"