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:
+109
-39
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user