A session row now records the control plane it belongs to, and the pane gets it explicitly instead of relying on env inheritance. - lib_py/control_plane.py (+ thin lib.sh wrappers mam_control_plane_json / mam_control_plane_prefix): the binding is WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR as absolute paths. - create: the binding is always persisted on the row (`control_plane`) and forwarded to the pane only when it differs from what --workspace alone implies, as leading K=V tokens that lib.sh's new-session already turns into --env (fresh pane) or `env K=V` (reused idle pane). pane.cmd_full and start_command stay unprefixed. A binding with whitespace that would have to be forwarded is refused with exit 2 (lib.sh splits the flags unquoted); the ~/.local/bin wrapper path records the binding but warns it is not forwarded. - resume: the row's binding wins over the caller's env (a difference is reported on stderr) and is forwarded again; a legacy row without one uses the caller's binding and update_yaml_resumed.sh records it. - stop: registry order MAM_JOBS_DIR > the row's mam_jobs_dir > $WORKSPACE_ROOT/.mam/jobs > .mam/jobs, exported for delegate_publish_event. - status: both get_job_status copies look in MAM_JOBS_DIR, the row's registry and the matching audit logs before the old candidates. - reconcile: MAM_JOBS_DIR wins in the subscribe heredoc and is passed in. Tests T3-0..T3-8 (mock herdr: forwarding is verified at the herdr-call level only; real pane env propagation is left to item 7). T3-7 is a static guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
204 lines
7.8 KiB
Bash
Executable File
204 lines
7.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# resume_session.sh — resume a stopped session
|
|
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> --session <name> [options]
|
|
|
|
Options:
|
|
--herdr-session NAME specify isolated herdr session name (alias: --herdr-server)
|
|
--dry-run Simulates resume flow (resolves binary, environment) without writing
|
|
any updates to YAML or DB. Safe to execute inside active write transactions.
|
|
EOF
|
|
}
|
|
|
|
WORKSPACE=""
|
|
AGENT=""
|
|
SESSION_NAME=""
|
|
HERDR_SERVER_OPT=""
|
|
HERDR_WORKSPACE_OPT=""
|
|
|
|
DRY_RUN=0
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--workspace) WORKSPACE="$2"; shift 2 ;;
|
|
--agent) AGENT="$2"; shift 2 ;;
|
|
--session) SESSION_NAME="$2"; shift 2 ;;
|
|
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
|
|
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
|
|
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
|
|
case "$AGENT" in
|
|
claude|agy|hermes|grok|opencode|codex) ;;
|
|
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
|
esac
|
|
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
|
|
|
# 1. Resolve the session id
|
|
UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \
|
|
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
|
|
|
|
# ISSUE-3: agy/hermes/opencode (Class A) are spawned without a UUID - their
|
|
# own conversation id is only assigned by the agent itself on its first turn.
|
|
# A session stopped before any message therefore has no UUID to resolve here,
|
|
# but IS still cleanly recoverable: relaunching the agent's plain first-run
|
|
# spawn command restores the exact state multi-agent-mux-create would have
|
|
# produced. claude/grok (Class B) always resolve a UUID via the
|
|
# verify_session.py:99-101 assigned-but-unverified escape hatch (see test_t8)
|
|
# and never reach this branch - do not extend the fallback to them.
|
|
FRESH_SPAWN=0
|
|
if [ -z "$UUID" ]; then
|
|
case "$AGENT" in
|
|
agy|hermes|opencode|codex) FRESH_SPAWN=1 ;;
|
|
*)
|
|
echo "ERROR: No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
if [ -n "$HERDR_SERVER_OPT" ]; then
|
|
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
|
else
|
|
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
|
|
export HERDR_SESSION_NAME
|
|
fi
|
|
|
|
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
|
|
export MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
|
|
else
|
|
export MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
|
|
fi
|
|
|
|
# 2. If herdr is alive, print warning or attach.
|
|
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
|
echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"
|
|
exit 0
|
|
fi
|
|
echo "herdr '$SESSION_NAME' already running."
|
|
# Just update YAML to make sure it's set to running
|
|
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
|
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
|
|
--herdr-session "$HERDR_SESSION_NAME" \
|
|
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
|
|
exit 0
|
|
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
|
|
|
|
# Determine CMD_FULL via adapter
|
|
if [ "$FRESH_SPAWN" = "1" ]; then
|
|
# ISSUE-3: no UUID to resume with - spawn the same plain first-run command
|
|
# multi-agent-mux-create would use (spawn-spec, not resume-spec).
|
|
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" 0 2>/dev/null || true)"
|
|
if [ -z "$CMD_FULL" ]; then
|
|
case "$AGENT" in
|
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
|
|
hermes) CMD_FULL="${RESOLVED_BIN} --yolo --accept-hooks" ;;
|
|
opencode) CMD_FULL="${RESOLVED_BIN} --auto --agent build" ;;
|
|
codex) CMD_FULL="${RESOLVED_BIN} --dangerously-bypass-approvals-and-sandbox" ;;
|
|
esac
|
|
fi
|
|
else
|
|
CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents resume-spec "$AGENT" "$RESOLVED_BIN" "$UUID" "$WORKSPACE" 2>/dev/null || true)"
|
|
if [ -z "$CMD_FULL" ]; then
|
|
case "$AGENT" in
|
|
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
|
|
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
|
|
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID --no-restore-cwd --yolo --accept-hooks" ;;
|
|
grok) CMD_FULL="${RESOLVED_BIN} --resume $UUID --permission-mode bypassPermissions" ;;
|
|
opencode) CMD_FULL="${RESOLVED_BIN} --session $UUID --auto --agent build" ;;
|
|
codex) CMD_FULL="${RESOLVED_BIN} resume --dangerously-bypass-approvals-and-sandbox $UUID" ;;
|
|
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
# Validate binary exists and is executable
|
|
if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then
|
|
if [ ! -x "$RESOLVED_BIN" ]; then
|
|
echo "ERROR: Agent binary is not executable: $RESOLVED_BIN" >&2
|
|
exit 1
|
|
fi
|
|
else
|
|
if ! command -v "$RESOLVED_BIN" >/dev/null 2>&1; then
|
|
echo "ERROR: Agent binary not found in PATH: $RESOLVED_BIN" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins
|
|
# over the caller's environment (a difference is reported on stderr); a legacy
|
|
# row without one uses the caller's binding, which update_yaml_resumed.sh then
|
|
# records. Forwarded to the pane only when it differs from --workspace's own.
|
|
ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
|
|
import json, os
|
|
d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}')
|
|
for s in d.get('herdr_sessions', []):
|
|
if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict):
|
|
print(json.dumps(s['control_plane']))
|
|
break
|
|
" 2>/dev/null || true)"
|
|
CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP" || true)"
|
|
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
|
|
|
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
|
echo "[dry-run] would spawn: $CMD_FULL"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$AGENT" = "opencode" ]; then
|
|
if [ -z "${OPENCODE_PERMISSION:-}" ]; then
|
|
export OPENCODE_PERMISSION='{"*":"allow"}'
|
|
fi
|
|
fi
|
|
|
|
# 4. Spawn new agent session (delegates to herdr translation shim)
|
|
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
|
if [ "$AGENT" = "claude" ]; then
|
|
# auto-handle trust / bypass dialogs
|
|
handle_startup_dialogs "$SESSION_NAME" 20
|
|
fi
|
|
|
|
# Wait for TUI readiness or let it settle
|
|
sleep 2
|
|
|
|
# 5. Update agent-sessions.yaml: status running, last_visible_status
|
|
CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
|
|
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
|
|
--herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \
|
|
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
|
|
|
|
echo "Successfully resumed $SESSION_NAME ($AGENT)"
|