diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index af39f2c..42826b3 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -22,7 +22,7 @@ if [ -z "${BASH_VERSION:-}" ]; then return 1 2>/dev/null || exit 1 fi SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WORKSPACE_ROOT="$(cd "$SKILL_DIR/../.." && pwd)" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" # Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells @@ -100,6 +100,46 @@ while [ "${1:-}" = "-L" ]; do shift 2 done +# Herdr's real isolation boundary is `--session ` (a whole separate +# server + socket, like tmux `-L`) — NOT `workspace create --label`, which is +# just a named subdivision inside ONE server and provides no actual isolation +# (agent/pane commands are server-global regardless of workspace). When +# HERDR_SERVER_NAME names a non-default session, make sure its headless server +# is actually running, then scope every real herdr call to it via `--session`. +_MAM_SESSION="${HERDR_SERVER_NAME:-default}" +if [ "$_MAM_SESSION" = "default" ]; then + _MAM_SESSION="" +else + _mam_session_running=$("$REAL_HERDR" session list --json 2>/dev/null | MAM_SESS="$_MAM_SESSION" python3 -c " +import sys, json, os +try: + d = json.loads(sys.stdin.read()) + name = os.environ.get('MAM_SESS', '') + print('1' if any(s.get('name') == name and s.get('running') for s in d.get('sessions', [])) else '0') +except Exception: + print('0') +" 2>/dev/null || echo "0") + if [ "$_mam_session_running" != "1" ]; then + # Headless bootstrap avoids herdr's "nested herdr is disabled" guard that + # blocks a normal interactive `herdr --session ` launch from inside + # an existing herdr pane (which is how MAM's own agents usually run). + nohup "$REAL_HERDR" --session "$_MAM_SESSION" server >/dev/null 2>&1 & + disown 2>/dev/null || true + for _mam_wait_i in $(seq 1 40); do + [ -S "${HOME:-$HOME_DIR}/.config/herdr/sessions/$_MAM_SESSION/herdr.sock" ] && break + sleep 0.25 + done + fi +fi + +_real_herdr() { + if [ -n "$_MAM_SESSION" ]; then + "$REAL_HERDR" --session "$_MAM_SESSION" "$@" + else + "$REAL_HERDR" "$@" + fi +} + wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cmd="${1:-}" if [ -z "$cmd" ]; then @@ -125,7 +165,7 @@ case "$cmd" in *) shift ;; esac done - "$REAL_HERDR" agent get "$sess" >/dev/null 2>&1 + _real_herdr agent get "$sess" >/dev/null 2>&1 ;; new-session) name="" ws="" run_cmd="" @@ -154,8 +194,7 @@ case "$cmd" in if [ -z "$run_cmd" ]; then run_cmd="${SHELL:-/bin/bash}" fi - "$REAL_HERDR" workspace create --label "${HERDR_SERVER_NAME:-default}" --cwd "${ws:-.}" >/dev/null 2>&1 || true - + parsed=$(python3 -c " import sys, shlex cmd = sys.argv[1] @@ -177,26 +216,22 @@ print('\t'.join(env_flags) + '\n' + ' '.join(binary_tokens)) final_cmd=$(echo "$parsed" | tail -n +2) fi - ws_label="${HERDR_SERVER_NAME:-default}" - ws_id="w1" - if [ "$ws_label" != "default" ]; then - ws_id=$("$REAL_HERDR" workspace list 2>/dev/null | WS_LABEL="$ws_label" python3 -c " -import sys, os, json + # Isolation now comes from `_MAM_SESSION` (a real, separate herdr + # session/server) rather than a workspace label match — just create a + # fresh workspace inside whatever session is active (default or + # isolated) and place the agent there. No cross-session label lookup + # needed since `--session` already scopes everything. + ws_id=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null | python3 -c " +import sys, json try: - ws_label = os.environ.get('WS_LABEL', '') - data = json.loads(sys.stdin.read()) - res = data.get('result', data) - for ws in res.get('workspaces', []): - if ws.get('label') == ws_label or ws.get('workspace_id') == ws_label: - print(ws.get('workspace_id')) - sys.exit(0) + d = json.loads(sys.stdin.read()) + print(d.get('result', {}).get('workspace', {}).get('workspace_id', '')) except Exception: pass ") - ws_id="${ws_id:-w1}" - fi + ws_id="${ws_id:-w1}" - eval "\"$REAL_HERDR\" agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $env_flags -- $final_cmd" + eval "_real_herdr agent start \"$name\" --workspace \"$ws_id\" --cwd \"${ws:-.}\" $env_flags -- $final_cmd" ;; kill-session) sess="" @@ -213,8 +248,20 @@ except Exception: *) shift ;; esac done - "$REAL_HERDR" session stop "$sess" >/dev/null 2>&1 || true - "$REAL_HERDR" session delete "$sess" >/dev/null 2>&1 || true + # NOTE: herdr's `session` verb operates on whole server instances (see + # `herdr session list`), not individual agent panes — `session stop/delete + # "$sess"` with a MAM session name always fails (silently, via `|| true`). + # Resolve the real pane_id via `agent get` and close just that pane instead. + pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " +import sys, json +try: + print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) +except Exception: + pass +" 2>/dev/null) + if [ -n "$pane_id" ]; then + _real_herdr pane close "$pane_id" >/dev/null 2>&1 || true + fi ;; list-panes) sess="" format="" @@ -239,26 +286,57 @@ except Exception: *) shift ;; esac done - info=$("$REAL_HERDR" agent get "$sess" 2>/dev/null || true) + # NOTE: `herdr agent get ` returns {"result": {"agent": {...}}} — + # there is no top-level "pane" key, and no "pid" field at all (only + # "pane_id", herdr's own wN:pN identifier). The real OS pid requires a + # second call to `pane process-info --pane `. + info=$(_real_herdr agent get "$sess" 2>/dev/null || true) + pane_id="" cwd="" cmd="" if [ -n "$info" ]; then - if [ "$format" = '#{pane_pid}|#{pane_current_path}|#{pane_current_command}' ]; then - python3 -c " + parsed=$(python3 -c " import sys, json try: - d = json.loads(sys.stdin.read()) - p = d.get('pane', d) - print(f\"{p.get('pid', '')}|{p.get('cwd', '')}|{p.get('command', p.get('argv', ''))}\") + a = json.loads(sys.stdin.read()).get('result', {}).get('agent', {}) +except Exception: + a = {} +print(a.get('pane_id', '')) +print(a.get('cwd', '')) +print(a.get('agent', '')) +" <<< "$info") + pane_id=$(sed -n '1p' <<< "$parsed") + cwd=$(sed -n '2p' <<< "$parsed") + cmd=$(sed -n '3p' <<< "$parsed") + fi + pid="" + if [ -n "$pane_id" ]; then + case "$format" in + *pane_pid*) + pid=$(_real_herdr pane process-info --pane "$pane_id" 2>/dev/null | python3 -c " +import sys, json +try: + pi = json.loads(sys.stdin.read()).get('result', {}).get('process_info', {}) + fg = pi.get('foreground_processes') or [] + print(fg[0]['pid'] if fg else pi.get('shell_pid', '')) except Exception: pass -" <<< "$info" - elif [ "$format" = '#{pane_pid}' ]; then - python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('pid', ''))" <<< "$info" - elif [ "$format" = '#{pane_current_path}' ]; then - python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('cwd', ''))" <<< "$info" - elif [ "$format" = '#{pane_current_command}' ]; then - python3 -c "import sys, json; d = json.loads(sys.stdin.read()); print(d.get('pane', d).get('command', d.get('pane', d).get('argv', '')))" <<< "$info" - fi +") + ;; + esac fi + case "$format" in + '#{pane_pid}|#{pane_current_path}|#{pane_current_command}') + printf '%s|%s|%s\n' "$pid" "$cwd" "$cmd" + ;; + '#{pane_pid}') + printf '%s\n' "$pid" + ;; + '#{pane_current_path}') + printf '%s\n' "$cwd" + ;; + '#{pane_current_command}') + printf '%s\n' "$cmd" + ;; + esac ;; capture-pane) sess="" @@ -275,7 +353,7 @@ except Exception: *) shift ;; esac done - "$REAL_HERDR" agent read "$sess" --source visible --lines 100 2>/dev/null || true + _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true ;; send-keys) sess="" key="" @@ -295,7 +373,18 @@ except Exception: if [ "$key" = "C-m" ] || [ "$key" = "Enter" ]; then key="Enter" fi - "$REAL_HERDR" pane send-keys "$sess" "$key" >/dev/null 2>&1 || true + # `pane send-keys` requires a real pane_id ("wN:pN"), not an agent name — + # resolve it via `agent get` first (agent-level commands accept names). + pane_id=$(_real_herdr agent get "$sess" 2>/dev/null | python3 -c " +import sys, json +try: + print(json.load(sys.stdin).get('result', {}).get('agent', {}).get('pane_id', '')) +except Exception: + pass +" 2>/dev/null) + if [ -n "$pane_id" ]; then + _real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true + fi ;; set-buffer) text="" @@ -330,14 +419,14 @@ except Exception: esac done if [ -f "$wrapper_dir/tmp_buffer" ]; then - "$REAL_HERDR" agent send "$sess" "$(cat "$wrapper_dir/tmp_buffer")" >/dev/null 2>&1 || true + _real_herdr agent send "$sess" "$(cat "$wrapper_dir/tmp_buffer")" >/dev/null 2>&1 || true fi ;; delete-buffer) rm -f "$wrapper_dir/tmp_buffer" ;; ls) - "$REAL_HERDR" agent list 2>/dev/null | python3 -c " + _real_herdr agent list 2>/dev/null | python3 -c " import sys, json try: data = json.load(sys.stdin) @@ -353,7 +442,7 @@ except Exception: " || true ;; *) - "$REAL_HERDR" "$cmd" "$@" + _real_herdr "$cmd" "$@" ;; esac EOF @@ -424,10 +513,15 @@ print(json.dumps(d, ensure_ascii=False)) PYEOF } +# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed +# all do `HERDR_SERVER_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 ` (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. resolve_herdr_workspace() { local session_name="$1" - local raw_label - raw_label=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c " + MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c " import sys, os, json name = os.environ['SESSION_NAME'] d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) @@ -436,26 +530,7 @@ for s in d.get('herdr_sessions', []): print(s.get('herdr_workspace') or s.get('herdr_server') or 'default') sys.exit(0) print(os.environ.get('HERDR_SERVER_NAME', 'default')) -") - if [ "$raw_label" = "default" ]; then - echo "w1" - else - local ws_id - ws_id=$(herdr workspace list 2>/dev/null | RAW_LABEL="$raw_label" python3 -c " -import sys, os, json -try: - raw_label = os.environ.get('RAW_LABEL', '') - data = json.loads(sys.stdin.read()) - res = data.get('result', data) - for ws in res.get('workspaces', []): - if ws.get('label') == raw_label or ws.get('workspace_id') == raw_label: - print(ws.get('workspace_id')) - sys.exit(0) -except Exception: - pass -") - echo "${ws_id:-w1}" - fi +" } # --------------------------------------------------------------------------- @@ -489,6 +564,10 @@ derive_session_name() { fi slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')" slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')" + slug="$(printf '%s' "$slug" | sed 's/^-*//')" + if [ -z "$slug" ]; then + slug="ws" + fi printf '%s-creator-%s' "$slug" "$agent" } @@ -1440,8 +1519,18 @@ _pane_dialog_open() { send_keys_safe() { local sess="$1" text="$2" job_id="${3:-adhoc}" local marker pre_submit deadline try - # Verification token: last 24 chars of the last non-empty line (multi-line safe). - marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | tail -c 24) + # Verification token: last 24 *characters* (not bytes — `tail -c` can split a + # multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match + # the properly-decoded rendered pane text) of the last non-empty line. + marker=$(printf '%s' "$text" | tr -d '\r' | awk 'NF {line=$0} END {print line}' | python3 -c "import sys; print(sys.stdin.read().rstrip('\n')[-24:], end='')") + # Whitespace-normalized copy for matching: some TUIs (e.g. cline's own + # message rendering) don't just soft-wrap with a bare newline — they add a + # leading-space "hanging indent" on the continuation line too, so removing + # only '\n' still leaves an extra space that breaks an exact literal match. + # Matching with all whitespace collapsed out sidesteps wrap formatting + # entirely, whatever shape it takes. + local marker_norm + marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]') _pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; } @@ -1470,7 +1559,11 @@ send_keys_safe() { pane_content=$(_pane_capture "$sess") if printf '%s\n' "$pane_content" | grep -Eq "Pasted text|paste again to expand"; then was_popup=1 - elif ! printf '%s\n' "$pane_content" | grep -Fq "$marker"; then + # Strip ALL whitespace before matching — the rendered pane soft-wraps long + # lines at the terminal width (and some TUIs add indentation on the + # continuation line too), which can split/reformat the marker across two + # visual lines and make a literal grep miss it even though the paste landed. + elif ! printf '%s' "$pane_content" | tr -d '[:space:]' | grep -Fq "$marker_norm"; then echo "send_keys_safe: paste not visible ($sess)" >&2 return 3 fi @@ -1485,7 +1578,7 @@ send_keys_safe() { if printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing…|[A-Za-z]+ing\.\.\.|esc to interrupt"; then return 0 fi - if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | grep -Fq "$marker" && [ "$cur_content" != "$pre_submit" ]; then + if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then return 0 elif [ "$was_popup" = "1" ] && \ ! printf '%s\n' "$cur_content" | grep -Eq "Pasted text|paste again to expand" && \ diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index b403755..3e80c65 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -66,6 +66,8 @@ When running multiple agent sessions alongside other workflows (e.g., cmux, Kanb To prevent this, you can run this skill inside an **isolated herdr server** using the `HERDR_SERVER_NAME` environment variable or the `--herdr-server ` flag (opt-in). +Under the hood this now maps to a real, separate herdr **session** (`herdr --session ` — 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 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 ` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't). + ### How to use 1. **Via Environment Variable**: ```bash @@ -95,8 +97,9 @@ tmc ls # Lists only your multi-agent sessions ``` ### Safety Rules (Pitfall 29 Summary) -- Never use global server termination commands like `herdr kill-server` or `herdr kill-session -a` as they will destroy all sessions on that server (including your own workspace sessions if they share the server). -- By using an isolated server via `HERDR_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference. +- Never use global server termination commands like `herdr server stop` as they will destroy every workspace/agent on that server (including your own workspace sessions if they share the server). (`kill-server`/`kill-session -a` are tmux-era names that don't exist in herdr's real CLI — see Pitfalls below.) +- By using an isolated server via `HERDR_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference — this is now backed by a genuinely separate `herdr` session/socket, not merely a workspace label. +- To deliberately tear down an *entire* isolated group at once (all its workspaces and agents), use `herdr session stop ` followed by `herdr session delete ` — this only affects that named session, never the default one. ## Workflow @@ -137,7 +140,9 @@ sleep 6 PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}') PANE_CWD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}') PANE_CMD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}') -HERDR_EPOCH=$(herdr list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1) +# `herdr list-sessions` doesn't exist (real or shimmed) — we just spawned this +# session ourselves, so stamp the epoch locally instead of round-tripping herdr. +HERDR_EPOCH=$(date +%s) ``` ## Registering the session in agent-sessions.yaml @@ -162,9 +167,13 @@ After spawn, append a new `herdr_sessions[]` entry to `.mam/agent-sessions.yaml` plan: account: version: - start_command: - attach_command: "herdr attach -t " - kill_command: "herdr kill-session -t " + start_command: "HERDR_SERVER_NAME= herdr new-session -d -s -x 140 -y 40 -c " + attach_command: "HERDR_SERVER_NAME= herdr agent attach " + kill_command: "HERDR_SERVER_NAME= herdr kill-session -t " + # All three require `source .agents/skills/lib.sh` first — `new-session`/`kill-session` + # are tmux-compat pseudo-commands the shim translates, and `HERDR_SERVER_NAME` is what + # the shim reads to route to the right isolated herdr *session* (real `herdr` has no + # env-var-based scoping of its own; `herdr_server: default` needs no prefix at all). ``` `cmd_full` per agent (this is the actual command line in the pane, not the resume command): @@ -196,11 +205,15 @@ The script handles the YAML append, pane capture, and the `last_visible_status` After spawn + YAML append: ```bash -# 1. herdr session is alive -herdr has-session -t "$SESSION_NAME" && echo OK || echo MISSING +# 1. herdr session is alive (real native command — no lib.sh needed) +herdr agent get "$SESSION_NAME" >/dev/null 2>&1 && echo OK || echo MISSING # 2. pane has the expected cmd + cwd -herdr list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}' +herdr agent get "$SESSION_NAME" | python3 -c " +import sys, json +a = json.load(sys.stdin)['result']['agent'] +print(f\"cmd={a['agent']} cwd={a['cwd']}\") +" # 3. agent-sessions.yaml has the new entry python3 -c " @@ -211,13 +224,15 @@ assert '$SESSION_NAME' in names, 'session not registered' print('OK:', names) " -# 4. Optional: check the TUI status via capture-pane -herdr capture-pane -t "$SESSION_NAME" -p -S -20 # TUI ready = agent banner visible, no dialog text +# 4. Optional: check the TUI status (real native command) +herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = agent banner visible, no dialog text ``` +> `herdr has-session` / `herdr list-panes` / `herdr capture-pane` above are tmux-compat pseudo-commands only understood after `source .agents/skills/lib.sh` (see `Workflow`) — the real `herdr` binary has no such subcommands. The block above uses the real `herdr agent get`/`herdr agent read` equivalents so it also works standalone. + ## When NOT to use this skill - **Resuming an old conversation** → `multi-agent-mux-resume` - **Killing an existing session** → `multi-agent-mux-stop` -- **Just attaching to an existing session** → `herdr attach -t ` (no skill needed) +- **Just attaching to an existing session** → `herdr agent attach ` (no skill needed) - **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 9dac3dc..5711606 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -231,7 +231,11 @@ HERDR_EPOCH=$(date +%s) NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') # 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4) -START_CMD="herdr agent start \"$SESSION_NAME\" --workspace \"${HERDR_SERVER_NAME:-default}\" --cwd \"$WORKSPACE\" -- $CMD_FULL" +# NOTE: this must match what `spawn()` actually ran above — env-var-driven +# isolation (HERDR_SERVER_NAME picked up by the lib.sh shim), not a +# `--workspace