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
+109 -39
View File
@@ -44,6 +44,7 @@ def mam_sandbox(tmp_path, monkeypatch):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
import sys
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
@@ -150,7 +151,13 @@ def save_state():
if "panes" in state:
disk_panes = disk_state.setdefault("panes", [])
for p in state["panes"]:
if not any(dp.get("pane_id") == p.get("pane_id") for dp in disk_panes):
matched = False
for i, dp in enumerate(disk_panes):
if dp.get("pane_id") == p.get("pane_id"):
disk_panes[i] = p
matched = True
break
if not matched:
disk_panes.append(p)
disk_calls = disk_state.setdefault("calls", [])
if sys.argv[1:] and (not disk_calls or disk_calls[-1] != sys.argv[1:]):
@@ -260,23 +267,42 @@ elif cmd1 == "pane":
else:
i += 1
panes_list = []
# Build panes from agents or state["panes"]
seen = set()
# Merge agent-derived panes with state["panes"] (dedupe by pane_id).
# Label-only panes live in state["panes"] and must remain visible
# even when other agents are registered.
for name, data in state.get("agents", {}).items():
ws_id = data.get("workspace_id", "w1")
if target_ws and ws_id != target_ws:
continue
panes_list.append({
"pane_id": data.get("pane_id", f"{ws_id}:p1"),
pid = data.get("pane_id", f"{ws_id}:p1")
entry = {
"pane_id": pid,
"workspace_id": ws_id,
"cwd": data.get("cwd", "."),
"tab_id": f"{ws_id}:t1",
"agent": data.get("agent", "claude")
})
if not panes_list:
for p in state.get("panes", []):
if target_ws and p.get("workspace_id") != target_ws:
continue
panes_list.append(p)
"tab_id": data.get("tab_id", f"{ws_id}:t1"),
"agent": data.get("agent", "claude"),
"name": name,
}
if data.get("label"):
entry["label"] = data["label"]
panes_list.append(entry)
seen.add(pid)
for p in state.get("panes", []):
if target_ws and p.get("workspace_id") != target_ws:
continue
pid = p.get("pane_id")
if pid in seen:
for existing in panes_list:
if existing.get("pane_id") == pid:
for k in ("label", "name"):
if p.get(k) and not existing.get(k):
existing[k] = p[k]
break
continue
panes_list.append(p)
if pid:
seen.add(pid)
print(json.dumps({"result": {"panes": panes_list}}))
sys.exit(0)
elif cmd2 == "split":
@@ -335,8 +361,70 @@ elif cmd1 == "pane":
state["agents"] = agents
save_state()
sys.exit(0)
else:
for p in state.get("panes", []):
if p.get("pane_id") == name:
p["sent_keys"] = p.get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
p["buffer"] = p.get("buffer", "") + "\\n\\nesc to interrupt"
save_state()
sys.exit(0)
sys.exit(1)
elif cmd2 == "send-text":
if len(args) < 4:
sys.exit(1)
pane_id = args[2]
text = args[3]
found = False
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or a_name == pane_id:
data["sent_text"] = data.get("sent_text", "") + text
data["buffer"] = data.get("buffer", "") + "\\n" + text
found = True
break
if not found:
for p in state.get("panes", []):
if p.get("pane_id") == pane_id:
p["sent_text"] = p.get("sent_text", "") + text
p["buffer"] = p.get("buffer", "") + "\\n" + text
found = True
break
if found:
save_state()
sys.exit(0)
sys.exit(1)
elif cmd2 == "read":
if len(args) < 3:
sys.exit(1)
pane_id = args[2]
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or a_name == pane_id:
print(data.get("buffer", "Ready"))
sys.exit(0)
for p in state.get("panes", []):
if p.get("pane_id") == pane_id:
print(p.get("buffer", ""))
sys.exit(0)
sys.stderr.write("Pane " + pane_id + " not found\\n")
sys.exit(1)
elif cmd2 == "rename":
if len(args) < 4:
sys.exit(1)
pane_id = args[2]
label = args[3]
panes = state.setdefault("panes", [])
renamed = False
for p in panes:
if p.get("pane_id") == pane_id:
p["label"] = label
renamed = True
break
if not renamed:
panes.append({"pane_id": pane_id, "label": label})
for a_name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id:
data["label"] = label
save_state()
sys.exit(0)
elif cmd2 == "process-info":
pane_id = ""
if "--pane" in args:
@@ -633,33 +721,15 @@ elif cmd1 == "agent":
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\n" + text + "\\n\\nesc to interrupt"
state["agents"] = agents
save_state()
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
elif cmd2 == "send":
if len(args) < 4:
sys.exit(1)
name = args[2]
text = args[3]
agents = state.get("agents", {})
matched_k = None
for k in agents:
if _match_agent(k, name):
matched_k = k
break
if matched_k:
agents[matched_k]["sent_text"] = agents[matched_k].get("sent_text", "") + text
if text in ("C-m", "Enter"):
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
else:
agents[matched_k]["buffer"] = agents[matched_k].get("buffer", "") + "\\\\n" + text
if "/exit" in text or "exit" in text or "Exit" in text:
agents[matched_k]["status"] = "stopped"
state["agents"] = agents
save_state()
print(json.dumps({"id": "cli:agent:prompt", "result": {"type": "ok"}}))
sys.exit(0)
else:
sys.stderr.write("Agent " + name + " not found\\\\n")
sys.exit(1)
sys.stderr.write("Agent " + name + " not found\\n")
sys.exit(1)
else:
# Real herdr has no `agent send`. Unknown subcommands must fail
# (exit 1) rather than silently succeeding.
sys.stderr.write("error: unrecognized subcommand '" + cmd2 + "'\\n")
sys.exit(1)
elif cmd1 == "session":
if len(args) < 2: