Files
multi-agent-mux/.agents/skills/multi-agent-mux-create/scripts/create_session.sh
T
GodopuandClaude Opus 5.5 e4c7c8fe9b fix: attach_command from one builder; no kill/start_command; stop closes a row-less session (Issue #71)
- attach_command is `herdr --session <server> agent attach <herdr name>`
  (`default` written too), built by lib_py.agents.sanitize.attach_command;
  create, resume and reconcile all use it. kill_command and start_command
  are no longer written, and status --json drops start_command.
- The two raw kill-session hints (create exit 43, id_order's orphan) print a
  stop-skill command with --herdr-session instead.
- stop_session.sh closes a session that has no registry row, herdr only and
  without --agent, when herdr runs it inside this workspace; another
  workspace's session is refused (exit 5), a missing one is exit 1, one that
  survives kill-session is exit 6. The registry is not changed.
- Docs: only the SKILL.md lines these changes made wrong; the skill block
  pins follow. Tests that asserted the removed fields keep their intent
  (issue50: create's own row; issue21: no control-root path in
  attach_command; tier2: the new form and the absent fields). S11 pins the
  shim to 4243da5, which changed it on purpose.

Refs #71

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:16:53 +00:00

773 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
# opencode needs no login (2.x: a free default model, keyless local
# providers); the adapter's auth_ok is the one verdict.
if ! "$(_delegate_py_bin)" -m lib_py.agents auth-ok opencode >/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 --standalone" ;;
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
# Issue #63: a same-cwd session of this post-discovered agent records its id first (lib.sh mam_id_order_wait).
mam_id_order_wait "$WORKSPACE" "$AGENT" "$SESSION_NAME" || exit 1
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: bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh --session $SESSION_NAME --herdr-session ${HERDR_SESSION_NAME:-default}" >&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")
# 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" 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'
from lib_py.agents.sanitize import attach_command
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. Names are unique per server only, so the
# row must be this server's (review 1e25cf2f F2: another server's row of the
# name was replaced). The pane pid is no identity: it is the pane's foreground
# process at the time it was read.
def _adopted_own(s):
return (s.get('last_visible_note') == 'auto-registered by monitor' and not s.get('delegate_job_id')
and (s.get('herdr_server') or s.get('herdr_session') or 'default') == server_name
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'],
},
'attach_command': attach_command(server_name, 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)"
# Issue #40: the first turn is done, so a post-discovered id is on disk -
# pin it now instead of waiting for a monitor (worktree creators run none).
delegate_autopin_completed "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$task_reg_dir"
;;
*)
# 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)"