feat(cli,registry): introduce --herdr-workspace option and decouple socket fallback chains

This commit is contained in:
2026-08-24 12:50:09 +09:00
parent 320f036575
commit e6e70dbb21
17 changed files with 1670 additions and 47 deletions
+59 -6
View File
@@ -1007,10 +1007,10 @@ print(resolved)
"
}
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
# all do `HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
# returns the isolated herdr *session* name to use for this MAM session row, not
# a workspace id. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
# resolve_herdr_session <session_name> [workspace]
#
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
# not a workspace label. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
# generated wrapper) — a workspace label match provides no actual isolation
# since agent/pane commands are server-global regardless of workspace.
@@ -1024,7 +1024,8 @@ ws = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
val = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace')
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
val = s.get('herdr_session') or s.get('herdr_server')
if val and val != 'default':
print(val)
sys.exit(0)
@@ -1048,8 +1049,60 @@ print(fallback or 'default')
"
}
# resolve_herdr_workspace <session_name> [workspace]
#
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
#
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
# ① row['herdr_workspace'] — 명시 기록
# ② row['pane']['cwd'] 의 슬러그 — 등록된 세션의 실제 작업 디렉터리
# ③ 인자 workspace 의 슬러그 — 미등록 세션 전용 폴백
# ④ 빈 문자열
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
# pane.cwd 를 물려받기 때문 (D5).
resolve_herdr_workspace() {
resolve_herdr_session "$@"
local session_name="$1"
local workspace="${2:-}"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
import sys, os, json, re
name = os.environ['SESSION_NAME']
ws = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
def slug(path):
if not path:
return ''
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)
# ① 명시 기록
if row and row.get('herdr_workspace'):
print(row['herdr_workspace']); sys.exit(0)
# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
if row:
derived = slug((row.get('pane') or {}).get('cwd', ''))
if derived:
print(derived); sys.exit(0)
# ③ 미등록(또는 cwd 부재) 세션 폴백
if ws:
derived = slug(ws)
if derived:
print(derived); sys.exit(0)
print('')
"
}
# ---------------------------------------------------------------------------
@@ -68,6 +68,7 @@ If any check fails → abort with a non-zero exit and report the reason (automat
When running multiple agent sessions alongside other workflows (e.g., cmux, background workers, manual herdr sessions), sharing the default herdr server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
To prevent this, you can run this skill inside an **isolated herdr session** using the `HERDR_SESSION_NAME` environment variable or the `--herdr-session <name>` flag (opt-in; alias: `--herdr-server`; legacy env alias: `HERDR_SERVER_NAME`).
Additionally, you can specify `--herdr-workspace <name>` to record a distinct project workspace label in `.mam/agent-sessions.yaml` (default: workspace slug without `mam-` prefix). Note that `--herdr-workspace` is a metadata label only and never selects a herdr daemon socket.
Under the hood this maps to a real, separate herdr **session** (`herdr --session <name>` — its own socket, its own `agent list`/`workspace list`, completely invisible to the default session and vice versa), not just a workspace label inside the same server. `lib.sh`'s shim bootstraps the named session's server headlessly (`herdr --session <name> server`, backgrounded) the first time it's needed, and scopes every subsequent herdr call to it automatically — this headless bootstrap is what lets it work even when the skill itself is running from inside another herdr-managed pane (a plain interactive `herdr --session <name>` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't).
@@ -79,7 +80,7 @@ Under the hood this maps to a real, separate herdr **session** (`herdr --session
```
2. **Via Option Flag**:
```bash
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --herdr-session multi-agent-canary
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --herdr-session multi-agent-canary --herdr-workspace my-project
```
3. **Submit Job Integration**:
You can automatically register a delegated job with a prompt when creating a session:
@@ -37,6 +37,10 @@ Options:
--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-onboard disable automatic onboarding job submission
@@ -53,6 +57,7 @@ SESSION_NAME=""
USE_WRAPPER=0
DRY_RUN=0
HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=1
@@ -65,6 +70,7 @@ while [ $# -gt 0 ]; do
--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 ;;
@@ -137,6 +143,10 @@ 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 는 사실을 세우는 쪽).
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"
@@ -197,7 +207,7 @@ spawn() {
}
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default})"
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
@@ -214,7 +224,7 @@ cleanup_herdr_on_error() {
trap cleanup_herdr_on_error EXIT
if [ -z "$HERDR_SERVER_OPT" ]; then
RESOLVED_SERVER="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
RESOLVED_SERVER="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
export HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-$RESOLVED_SERVER}"
fi
@@ -285,6 +295,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
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" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
name = os.environ['SESSION_NAME']
@@ -313,6 +324,7 @@ entry = {
'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,
@@ -40,7 +40,7 @@ fi
# Source EARLY (before any herdr usage in run_agent) — this is what turns
# plain `herdr` into the tmux-compat shim (herdr() function) and provides
# resolve_herdr_workspace/send_keys_safe. Sourcing it late meant the
# resolve_herdr_session/send_keys_safe. Sourcing it late meant the
# has-session pre-flight check below used to hit the real herdr binary with
# a nonexistent subcommand and always fail.
source "$SCRIPT_DIR/../lib.sh"
@@ -463,7 +463,7 @@ run_agent() {
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
# delegation reach an agent living in an isolated herdr session (e.g. one
# created with --herdr-server) instead of silently looking in "default".
export HERDR_SESSION_NAME="$(resolve_herdr_workspace "$sess" "$WORKDIR")"
export HERDR_SESSION_NAME="$(resolve_herdr_session "$sess" "$WORKDIR")"
if ! herdr has-session -t "$sess" 2>/dev/null; then
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
@@ -132,7 +132,8 @@ _changed = False
for s in d.get('herdr_sessions', []):
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
_name = s.get('name')
_srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
_srv = s.get('herdr_session') or s.get('herdr_server') or 'default'
if _event in ('completed', 'cancelled'):
s['delegate_job_id'] = None
print('MQTT Monitor: job ' + _event + ' on ' + str(_name) + ' — session kept alive', flush=True)
@@ -316,12 +317,23 @@ fi
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
read -r -d '' RECON_SRC <<'PYEOF' || true
import os, json, glob, subprocess, time, sqlite3
import os, json, glob, subprocess, time, sqlite3, re
from datetime import datetime, timezone
import yaml
from lib_py.verify_session import verify_session_uuid, workspace_key
def _slug(path):
if not path:
return ''
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
skills_dir = os.environ.get('SKILLS_DIR', '')
@@ -386,7 +398,8 @@ if 'HERDR_SESSION_NAME' in os.environ:
elif 'HERDR_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
for s in d.get('herdr_sessions', []):
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
srv = s.get('herdr_session') or s.get('herdr_server') or 'default'
unique_servers.add(srv)
try:
@@ -396,8 +409,6 @@ try:
cmd += ['-L', srv]
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
r = subprocess.run(cmd, capture_output=True, text=True)
import sys
sys.stderr.write(f"LS CMD: {cmd} | RC: {r.returncode} | STDOUT: {r.stdout} | STDERR: {r.stderr}\n")
if r.returncode == 0:
for line in r.stdout.strip().split('\n'):
if not line or '|' not in line:
@@ -423,9 +434,7 @@ try:
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
if not is_empty:
herdr_confirmed = False
except Exception as ex:
import sys
sys.stderr.write(f"EX IN RECONCILE LS: {ex}\n")
except Exception:
herdr_confirmed = False
@@ -472,8 +481,7 @@ yaml_sessions = d.get('herdr_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
# === drift A: herdr dead + YAML running → auto-terminate ===
# herdr 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
# === drift A: YAML running + herdr dead → mark terminated ===
if herdr_confirmed:
for s in yaml_sessions:
name = s.get('name')
@@ -483,7 +491,8 @@ if herdr_confirmed:
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
if s.get('status') in ('terminated', 'archived', 'stopped'):
continue
srv = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
srv = s.get('herdr_session') or s.get('herdr_server') or 'default'
if (name, srv) not in alive_set and (_sanitize(name), srv) not in alive_set:
s['status'] = 'terminated'
s['terminated_at'] = now_iso
@@ -498,7 +507,8 @@ if herdr_confirmed:
if herdr_confirmed:
for t in herdr_sessions:
name = t['name']
if name in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
srv = t.get('server', 'default')
if (name, srv) in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
continue
workspace_root = os.environ.get('WORKSPACE_ROOT')
if not workspace_root:
@@ -518,8 +528,7 @@ if herdr_confirmed:
if not agent:
# Check MAM_MANAGED env marker from pane process environment if available
srv_opt = t.get('server', 'default')
pm_check = pane_meta(name, srv_opt)
pm_check = pane_meta(name, srv)
if pm_check and pm_check.get('pid'):
try:
pid_val = pm_check['pid']
@@ -539,7 +548,6 @@ if herdr_confirmed:
if not agent:
continue
srv = t.get('server', 'default')
pm = pane_meta(name, srv)
if not pm:
continue
@@ -564,6 +572,8 @@ if herdr_confirmed:
'herdr_session_created_at': datetime.fromtimestamp(created_epoch, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'herdr_session_epoch': created_epoch,
'herdr_session': srv,
'herdr_server': srv,
'herdr_workspace': _slug(pm['cwd']),
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
@@ -73,7 +73,7 @@ if [ -z "$UUID" ]; then
exit 1
fi
export HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "$WORKSPACE")"
export HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "$WORKSPACE")"
# 2. If herdr is alive, attach. Done.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
@@ -22,6 +22,7 @@ WORKSPACE=""
AGENT=""
SESSION_NAME=""
HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT=""
DRY_RUN=0
@@ -31,6 +32,7 @@ while [ $# -gt 0 ]; do
--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 ;;
@@ -71,7 +73,8 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
# 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-session "$HERDR_SESSION_NAME" \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
exit 0
fi
@@ -135,6 +138,7 @@ sleep 2
# 5. Update agent-sessions.yaml: status running, last_visible_status
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \
--herdr-session "$HERDR_SESSION_NAME"
--herdr-session "$HERDR_SESSION_NAME" \
${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}
echo "Successfully resumed $SESSION_NAME ($AGENT)"
@@ -21,6 +21,7 @@ AGENT=""
WORKSPACE=""
ROLE=""
HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT=""
while [ $# -gt 0 ]; do
case "$1" in
@@ -30,6 +31,7 @@ while [ $# -gt 0 ]; do
--workspace) WORKSPACE="$2"; shift 2 ;;
--role) ROLE="$2"; shift 2 ;;
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
@@ -48,6 +50,15 @@ else
export HERDR_SERVER_OPT_EXPLICIT="0"
fi
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
export MAM_WS_LABEL_EXPLICIT="1"
else
MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-}")"
export MAM_WS_LABEL_EXPLICIT="0"
fi
export MAM_WS_LABEL
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
@@ -92,7 +103,8 @@ for s in d.get('herdr_sessions', []):
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
NOW_EPOCH="$NOW_EPOCH" TARGET_WORKSPACE="${WORKSPACE:-$WORKSPACE_ROOT}" ROLE="$ROLE" \
PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" <<'PYEOF'
PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" HERDR_SERVER_OPT_EXPLICIT="${HERDR_SERVER_OPT_EXPLICIT:-0}" \
MAM_WS_LABEL="$MAM_WS_LABEL" MAM_WS_LABEL_EXPLICIT="${MAM_WS_LABEL_EXPLICIT:-0}" <<'PYEOF'
name = os.environ['SESSION_NAME']
uuid = os.environ['UUID']
agent = os.environ['AGENT']
@@ -112,6 +124,7 @@ if target is None:
pwd = os.path.abspath(ws_root)
default_server = 'mam-' + os.path.basename(pwd).lower().replace('_', '-')
server_name = os.environ.get('HERDR_SESSION_NAME', default_server)
wsl = os.environ.get('MAM_WS_LABEL', '')
target = {
'name': name,
'status': 'running',
@@ -120,6 +133,7 @@ if target is None:
'herdr_session_epoch': epoch,
'herdr_session': server_name,
'herdr_server': server_name,
'herdr_workspace': wsl,
'delegate_job_id': None,
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
'start_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
@@ -137,6 +151,10 @@ else:
target['start_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
target['attach_command'] = f'HERDR_SESSION_NAME={sn} herdr agent attach {name}'
target['kill_command'] = f'HERDR_SESSION_NAME={sn} herdr kill-session -t {name}'
wsl = os.environ.get('MAM_WS_LABEL', '')
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
if wsl and (ws_explicit or not target.get('herdr_workspace')):
target['herdr_workspace'] = wsl
target['status'] = 'running'
target.pop('terminated_at', None)
@@ -121,6 +121,19 @@ def get_job_status(s):
return (jid, 'unknown')
def _slug(path):
if not path:
return ''
import re
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
sessions_detail = []
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
@@ -129,15 +142,18 @@ def is_alive(name, server):
for s in d.get('herdr_sessions', []):
name = s.get('name', '?')
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
server = s.get('herdr_session') or s.get('herdr_server') or 'default'
jid, jstatus = get_job_status(s)
pane = s.get('pane') or {}
wslabel = s.get('herdr_workspace') or _slug(pane.get('cwd', '')) or None
sessions_detail.append({
# Fields named/typed to match the reviewed D8 contract
# (.mam/jobs/40bdce88/claude-reports/report-final.md §3.1) exactly —
# mam_core maps this straight onto its Session/Pane/Drift models.
'name': name,
'server': server,
'herdr_workspace': wslabel,
'status': s.get('status', '?'),
'herdr_alive': is_alive(name, server),
'cmd': pane.get('cmd'),
@@ -224,13 +240,26 @@ def get_job_status(s):
return (jid, 'unknown')
def _slug(path):
if not path:
return ''
import re
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize
sessions = d.get('herdr_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
print("=" * 136)
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print("-" * 136)
print("=" * 150)
print(f"{'NAME':<44} {'SOCKET':<12} {'WORKSPACE':<14} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print("-" * 150)
if not sessions:
print("(no sessions registered)")
def is_alive(name, server):
@@ -238,14 +267,16 @@ def is_alive(name, server):
for s in sessions:
name = s.get('name', '?')
server = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace') or 'default'
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다.
server = s.get('herdr_session') or s.get('herdr_server') or 'default'
wslabel = s.get('herdr_workspace') or _slug((s.get('pane') or {}).get('cwd', '')) or '-'
status = s.get('status', '?')
herdr = 'alive' if is_alive(name, server) else 'dead'
cmd = (s.get('pane') or {}).get('cmd', '?')
res = resume_on_disk(s)
jid, jstatus = get_job_status(s)
drs = ','.join(drift_by_name.get(name, [])) or '-'
print(f"{name:<44} {server:<12} {status:<10} {herdr:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
print(f"{name:<44} {server:<12} {wslabel:<14} {status:<10} {herdr:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
# drifts not tied to a registered row (e.g. class B unregistered, class D cache)
known = {s.get('name') for s in sessions}
extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known]
+1 -1
View File
@@ -16,7 +16,7 @@ metadata:
# Multi-Agent Stop — Stop an Agent herdr Session
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-monitor` (live status).
> **Herdr Isolation**: `stop` 명령은 YAML의 `herdr_session` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `HERDR_SESSION_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
> **Herdr Isolation**: `stop` 명령은 YAML의 `herdr_session` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `HERDR_SESSION_NAME` 환경변수를 수동으로 지정할 필요가 없습니다. (`--herdr-workspace`는 CLI 대칭성을 위해 파서에서 허용되지만 소켓 라우팅에는 영향을 주지 않습니다.)
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
@@ -50,6 +50,10 @@ Arguments:
(falls back to the registry record: agent field ->
session-name suffix -> pane.cmd)
--herdr-session <name> — specify isolated herdr session name (alias: --herdr-server)
--herdr-workspace <name> — recorded label only; never selects a socket
(use --herdr-session for that). Note: stop has no
--workspace flag — the session's own workspace is
read from its registry row, not from where you stand.
--reason <reason> — stop_reason field (default: manual_stop)
--purge-conversation — also delete on-disk conversation artifacts;
status becomes terminated and resume is impossible
@@ -63,6 +67,7 @@ EOF
SESSION_NAME=""
AGENT=""
HERDR_SERVER_OPT=""
HERDR_WORKSPACE_OPT=""
PURGE=0
YES=0
CAPTURE_ID=1
@@ -75,6 +80,7 @@ while [ $# -gt 0 ]; do
--session) SESSION_NAME="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
--purge-conversation) PURGE=1; shift ;;
--yes) YES=1; shift ;;
--reason) REASON="$2"; shift 2 ;;
@@ -104,7 +110,7 @@ fi
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
else
HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
export HERDR_SESSION_NAME
fi