fix(herdr): resolve shim routing defects, add workspace scoping, and bump to v3.0.1

- Resolve Herdr shim 5 routing & paste defects (ISSUE-1 ~ ISSUE-5):
  * paste-buffer: use pane send-text without auto-enter, propagate rc=3 to send_keys_safe
  * exact-match pane resolution: remove substring matching ('in tn') across all branches
  * workspace scoping: introduce HERDR_WORKSPACE_ID and .mam/herdr_workspace_id persistence
  * unified resolver: single _resolve_herdr_pane_id helper across shim commands
- Resolve dialog token false-positive on 'Yes, try it' tip and isolate fullscreen modal rejection
- Sync mock Herdr CLI contracts in tests/conftest.py
- Add contract tests H-15~H-23 and regression tests D-4~D-7 (412 tests, 100% PASS)
- Add multi-agent loop plans, review reports, and bug report
- Update framework and skill packages to v3.0.1
This commit is contained in:
2026-08-27 21:42:37 +09:00
parent 4bbd03bf2d
commit c3631e2aa1
18 changed files with 1982 additions and 115 deletions
+239 -50
View File
@@ -59,7 +59,7 @@ for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME
done
# Central TUI dialog and readiness validation tokens (OP-6)
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary|Yes, try it'
_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary'
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
# Workspace-relative defaults with environment overrides (Phase Z)
@@ -235,6 +235,152 @@ _sanitize_herdr_agent_name() {
fi
printf '%s\n' "${s:0:32}"
}
# ---------------------------------------------------------------------------
# Workspace scoping (ISSUE-3).
#
# Hard filter when HERDR_WORKSPACE_ID is set, else the workspace id this
# shim last persisted under $WORKSPACE_ROOT/.mam/herdr_workspace_id.
# Do not infer from cwd — that silently blocks legitimate cross-workspace
# lookups. No env and no file keeps the previous server-global lookup.
# ---------------------------------------------------------------------------
_herdr_ws_id_file() {
if [ -n "${WORKSPACE_ROOT:-}" ]; then
printf '%s\n' "$WORKSPACE_ROOT/.mam/herdr_workspace_id"
fi
}
_herdr_persist_ws_id() {
local id="$1" f
[ -n "$id" ] || return 0
f=$(_herdr_ws_id_file)
[ -n "$f" ] || return 0
mkdir -p "$(dirname "$f")" 2>/dev/null || true
printf '%s\n' "$id" > "$f" 2>/dev/null || true
}
_herdr_ws_scope() {
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
printf '%s\n' "$HERDR_WORKSPACE_ID"
return 0
fi
local f id=""
f=$(_herdr_ws_id_file)
if [ -n "$f" ] && [ -f "$f" ]; then
id=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
fi
printf '%s\n' "$id"
}
# _herdr_agent_get_scoped <name> [workspace_id]
#
# agent get with the same workspace predicate as _resolve_herdr_pane_id.
# Prints pane_id on stdout and returns 0 when the agent exists and is in
# scope. Returns 1 (prints nothing) if missing or in another workspace.
# Callers MUST wrap with `|| true`.
_herdr_agent_get_scoped() {
local name="$1"
local target_ws pid=""
# Default scope is the explicit env var only. The persisted workspace
# file is NOT used here: one MAM workspace can own several herdr
# workspaces (layout overflow / parallel create), so a single file
# must not reject a uniquely named agent. File scope applies to pane
# list / label lookup via _herdr_ws_scope.
if [ "$#" -ge 2 ]; then
target_ws="$2"
else
target_ws="${HERDR_WORKSPACE_ID:-}"
fi
[ -n "$name" ] || return 1
pid=$(_real_herdr agent get "$name" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tws = os.environ.get('TARGET_WS', '')
try:
a = json.load(sys.stdin).get('result', {}).get('agent', {})
if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
sys.exit(1)
pid = a.get('pane_id') or ''
if pid:
print(pid)
sys.exit(0)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "")
if [ -n "$pid" ]; then
printf '%s\n' "$pid"
return 0
fi
return 1
}
# _resolve_herdr_pane_id <target> [workspace_id]
#
# Resolve a session name / label to a real pane_id ("wN:pM").
# Strict order (no substring matching at any step):
# 1. herdr agent get <sanitized_name> (workspace-scoped)
# 2. herdr agent get <raw_name> (workspace-scoped)
# 3. herdr pane list [--workspace WS] matching
# 3-a. label exact
# 3-b. name exact
# 3-c. agent exact
# On success print pane_id on stdout and return 0; on failure print nothing
# and return 1. Callers MUST wrap with `|| true` (shim runs under set -e).
_resolve_herdr_pane_id() {
local target="$1"
local target_ws="${2:-$(_herdr_ws_scope)}"
local sat pid=""
sat=$(_sanitize_herdr_agent_name "$target")
local cand get_ws
if [ "$#" -ge 2 ]; then
get_ws="$target_ws"
else
get_ws="${HERDR_WORKSPACE_ID:-}"
fi
for cand in "$sat" "$target"; do
[ -n "$cand" ] || continue
pid=$(_herdr_agent_get_scoped "$cand" "$get_ws" 2>/dev/null || true)
[ -n "$pid" ] && break
done
if [ -z "$pid" ]; then
local ws_flag=()
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
| TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tn = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '')
try:
panes = json.load(sys.stdin).get('result', {}).get('panes', [])
# Client-side filter in case an older herdr ignores --workspace.
if tws:
panes = [p for p in panes if p.get('workspace_id') == tws]
# ISSUE-2: exact match only.
for key in ('label', 'name', 'agent'):
for p in panes:
v = p.get(key)
if v and (v == tn or v == tsa):
pid = p.get('pane_id') or ''
if pid:
print(pid)
sys.exit(0)
except Exception:
pass
sys.exit(1)
" 2>/dev/null || echo "")
fi
# Real pane_id values look like 'w1E:p1' — the workspace segment is
# alphanumeric. A digits-only pattern would reject every live pane_id.
if [[ "$pid" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
printf '%s\n' "$pid"
return 0
fi
return 1
}
cmd="${1:-}"
if [ -z "$cmd" ]; then
echo "herdr shim: no command specified" >&2
@@ -250,26 +396,29 @@ case "$cmd" in
local raw="$1"
local sat
sat=$(_sanitize_herdr_agent_name "$raw")
if _real_herdr agent get "$sat" >/dev/null 2>&1; then
if [ -n "$(_herdr_agent_get_scoped "$sat" 2>/dev/null || true)" ]; then
echo "$sat"
return 0
fi
if _real_herdr agent get "$raw" >/dev/null 2>&1; then
if [ "$raw" != "$sat" ] && [ -n "$(_herdr_agent_get_scoped "$raw" 2>/dev/null || true)" ]; then
echo "$raw"
return 0
fi
local from_list
from_list=$(_real_herdr agent list 2>/dev/null | TARGET_NAME="$raw" python3 -c '
from_list=$(_real_herdr agent list 2>/dev/null | TARGET_NAME="$raw" TARGET_SAN="$sat" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
import sys, json, os
tn = os.environ.get("TARGET_NAME", "")
tn = os.environ.get("TARGET_NAME", "")
tsa = os.environ.get("TARGET_SAN", "")
tws = os.environ.get("TARGET_WS", "")
try:
d = json.loads(sys.stdin.read())
for a in d.get("result", {}).get("agents", []):
agents = d.get("result", {}).get("agents", [])
if tws:
agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
name = a.get("name", "")
agent = a.get("agent", "")
pane_id = a.get("pane_id", "")
if name == tn or (not name and agent and agent in tn):
print(pane_id or agent)
if name and (name == tn or name == tsa):
print(a.get("pane_id") or name)
sys.exit(0)
except Exception:
pass
@@ -279,6 +428,12 @@ sys.exit(1)
echo "$from_list"
return 0
fi
# Explicit-env miss: do not fall through to a name that agent prompt
# would deliver into another workspace (ISSUE-3 / F-2b). File-only
# scope does not trip this — see _herdr_agent_get_scoped.
if [ -n "${HERDR_WORKSPACE_ID:-}" ]; then
return 1
fi
echo "$raw"
}
@@ -286,17 +441,29 @@ sys.exit(1)
t="$1"
txt="$2"
shift 2
tgt=$(_resolve_herdr_target "$t")
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent prompt "$tgt" "$txt" "$@"
elif [ "$sub" = "get" ] && [ $# -ge 1 ]; then
t="$1"
shift
tgt=$(_resolve_herdr_target "$t")
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent get "$tgt" "$@"
elif [ "$sub" = "read" ] && [ $# -ge 1 ]; then
t="$1"
shift
tgt=$(_resolve_herdr_target "$t")
tgt=$(_resolve_herdr_target "$t" || true)
if [ -z "$tgt" ]; then
echo "Error: no in-scope herdr agent for '$t'" >&2
exit 1
fi
_real_herdr agent read "$tgt" "$@"
else
_real_herdr agent "$sub" "$@"
@@ -317,10 +484,11 @@ sys.exit(1)
*) shift ;;
esac
done
if _real_herdr agent get "$(_sanitize_herdr_agent_name "$sess")" >/dev/null 2>&1 || _real_herdr agent get "$sess" >/dev/null 2>&1; then
if [ -n "$(_herdr_agent_get_scoped "$(_sanitize_herdr_agent_name "$sess")" 2>/dev/null || true)" ] \
|| [ -n "$(_herdr_agent_get_scoped "$sess" 2>/dev/null || true)" ]; then
exit 0
fi
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" python3 -c '
if _real_herdr agent list 2>/dev/null | TARGET_NAME="$sess" TARGET_WS="$(_herdr_ws_scope)" python3 -c '
import sys, json, os
try:
sys.path.insert(0, os.path.abspath(".agents/skills"))
@@ -329,12 +497,15 @@ except Exception:
sanitize_herdr_agent_name = lambda s: s.lower()[:32] if s else "agent"
tn = os.environ.get("TARGET_NAME", "")
stn = sanitize_herdr_agent_name(tn)
tws = os.environ.get("TARGET_WS", "")
try:
d = json.loads(sys.stdin.read())
agents = d.get("result", {}).get("agents", [])
if tws:
agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
an = a.get("name", "")
if (an and (an == tn or an == stn)) or (not an and a.get("agent") and a.get("agent") in tn):
if an and (an == tn or an == stn):
sys.exit(0)
except Exception:
pass
@@ -342,6 +513,9 @@ sys.exit(1)
'; then
exit 0
fi
if [ -n "$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)" ]; then
exit 0
fi
exit 1
;;
new-session)
@@ -478,7 +652,7 @@ except Exception:
fi
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
@@ -492,7 +666,7 @@ except Exception:
# W2b: Overflow threshold reached — force create fresh workspace
existing_ws=""
else
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "")
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
@@ -516,11 +690,25 @@ try:
print(res.get('root_pane', {}).get('pane_id') or w_obj.get('root_pane_id') or res.get('root_pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
ws_id=$(echo "$ws_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('workspace', {}).get('workspace_id') or '')
except Exception:
pass
" 2>/dev/null || echo "")
else
if [ -n "${MAM_WS_LABEL:-}" ]; then
_real_herdr workspace rename "$existing_ws" "$MAM_WS_LABEL" >/dev/null 2>&1 || true
fi
ws_id="$existing_ws"
fi
if [ -n "${ws_id:-}" ]; then
export HERDR_WORKSPACE_ID="$ws_id"
_herdr_persist_ws_id "$ws_id"
fi
if [ -z "$target_pane" ]; then
@@ -584,23 +772,12 @@ except Exception:
# "$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.
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_real_herdr agent get "$agent_target" 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 || _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)
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
fi
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 || _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 \
|| _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true
;;
list-panes)
sess="" format=""
@@ -700,7 +877,15 @@ except Exception:
esac
done
agent_target=$(_sanitize_herdr_agent_name "$sess")
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null || _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null \
|| _real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
|| _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
else
_real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
|| _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
fi
;;
send-keys)
sess="" key=""
@@ -723,19 +908,7 @@ except Exception:
# `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).
agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_real_herdr agent get "$agent_target" 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 || _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)
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
_real_herdr pane send-keys "$pane_id" "$key" >/dev/null 2>&1 || true
else
@@ -818,12 +991,23 @@ except Exception:
done
buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
if [ -f "$buffer_dir/$buf" ]; then
_real_herdr agent send "$sess" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1 || true
else
if [ ! -f "$buffer_dir/$buf" ]; then
echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
exit 1
fi
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -z "$pane_id" ]; then
# herdr has no `agent send` subcommand. Returning success here would let
# send_keys_safe submit an empty prompt.
echo "Error: paste-buffer could not resolve a pane for '$sess'" >&2
exit 1
fi
# Insert only. Submission is owned exclusively by send_keys_safe (ISSUE-1).
# A combined insert-and-submit command would double-submit.
if ! _real_herdr pane send-text "$pane_id" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1; then
echo "Error: pane send-text failed for '$sess' ($pane_id)" >&2
exit 1
fi
;;
delete-buffer)
buf="tmp_buffer"
@@ -1802,8 +1986,13 @@ send_keys_safe() {
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
_sks_herdr set-buffer -b "$sks_buf" "$text"
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
local _paste_rc=0
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
if [ "$_paste_rc" != "0" ]; then
echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
return 3
fi
local was_popup=0
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]] || [[ "$sess" =~ "grok" ]]; then
# Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, cline, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos, windows]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture.
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 3.0.0
version: 3.0.1
author: godopu
license: MIT
platforms: [linux, macos]