Files
multi-agent-mux/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh
T
GodopuandClaude Fable 5.1 05b0ea4762 fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one
Two probes from the merge-side codex review (its report was lost to an API
outage; the probes and their results were recovered from its transcript):

- create_session.sh and resume_session.sh set AUTO_TRUST=1 and then
  exported MAM_AUTO_TRUST from it, so the documented MAM_AUTO_TRUST=0
  without a flag reached handle_startup_dialogs as 1: the operator had
  turned automatic trust off and it stayed on. Now AUTO_TRUST defaults to
  the environment value and an explicit --auto-trust/--no-auto-trust wins.
- _mam_dialog_pick exited on the first dialog inside its resend cooldown
  instead of looking further down the screen, so a skipped (--no-auto-trust)
  or just-answered trust prompt hid the codex update notice below it, which
  the docs say is always answered. It continues to the next dialog now; S5
  and S5b (one send per cooldown, one retry after it) still hold.

Regressions: S6c, S5f (startup path), R3b and C3 (resume/create, env only,
flag over env). Each fails without the fix. The create skill doc names the
environment form and its precedence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-24 06:47:57 +09:00

253 lines
11 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)
--no-auto-trust do not answer a start-up directory-trust dialog (default: answer it;
other blocking notices are always answered) - same switch as create
--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
# Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh
# one, so resume takes the same switch as create (MAM_AUTO_TRUST is what
# handle_startup_dialogs reads).
AUTO_TRUST="${MAM_AUTO_TRUST:-1}" # the environment form of the same switch; a flag wins
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 ;;
--auto-trust) AUTO_TRUST=1; shift ;;
--no-auto-trust) AUTO_TRUST=0; shift ;;
--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; }
export MAM_AUTO_TRUST="$AUTO_TRUST"
# Issue #21 phase 2 (A11): a session resumes only in the directory it ran in
# (its conversation is keyed by that cwd; a class A agent would otherwise start
# a fresh conversation in the wrong tree). Existence first: mam_abs_workspace
# returns a missing path unchanged, which would read as "a different path".
ROW_CWD="$(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"]:
print((s.get("pane") or {}).get("cwd", "") or ""); break
' 2>/dev/null || true)"
if [ -n "$ROW_CWD" ]; then
if [ ! -d "$ROW_CWD" ]; then
echo "ERROR: session '$SESSION_NAME' ran in '$ROW_CWD', which no longer exists (worktree removed?). It cannot be resumed; recreate the worktree at that path, or create a new session." >&2
exit 2
fi
if [ "$(mam_abs_workspace "$ROW_CWD")" != "$(mam_abs_workspace "$WORKSPACE")" ]; then
echo "ERROR: session '$SESSION_NAME' was created in '$ROW_CWD'; resume it with --workspace '$ROW_CWD' (its conversation is keyed by that directory)." >&2
exit 2
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 - on the already-running path too (phase 2, A13). A binding that
# cannot be computed is refused for a worktree and warned about for the
# control root itself (A12), as in create.
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_ROOT="$(printf '%s' "$ROW_CP" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("workspace_root") or "")
except Exception: print("")' 2>/dev/null || true)"
CP_ROOT="${CP_ROOT:-$WORKSPACE_ROOT}"
cp_rc=0
CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP")" || cp_rc=$?
if [ "$cp_rc" -ne 0 ] || [ -z "$CP_JSON" ]; then
if [ "$(mam_abs_workspace "$WORKSPACE")" != "$(mam_abs_workspace "$CP_ROOT")" ]; then
echo "ERROR: cannot compute the control-plane binding (rc=$cp_rc) for --workspace '$WORKSPACE' (control root '$CP_ROOT'); refusing to resume 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 kept without control_plane" >&2
CP_JSON=""
fi
# 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
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" \
${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
# The binding (computed above) is forwarded to the pane only when it differs
# from --workspace's own; a forward with whitespace is refused (spawn path only).
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"
# Issue #24: every agent, the same call create makes. The signatures and accept
# keys come from the agent adapter; this used to run only for claude, so a
# resumed agy/grok/codex sat on its trust prompt.
handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
# 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)"