fix(lib): finalize herdr 0.7.4 contract refactor and layout policy

This commit is contained in:
2026-08-13 20:50:03 +09:00
parent 2bd59fce9b
commit 657a749f43
7 changed files with 1159 additions and 107 deletions
+255 -63
View File
@@ -36,6 +36,8 @@ def mam_sandbox(tmp_path, monkeypatch):
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
import sys
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
@@ -105,9 +107,21 @@ if os.path.exists(state_file):
# Record the command call
state["calls"].append(sys.argv[1:])
try:
with open(state_file + ".trace", "a") as tf:
tf.write(f"PID {os.getpid()} ARGS: {sys.argv[1:]}\\n")
tf.write(f" AGENTS: {list(state.get('agents', {}).keys())}\\n")
except Exception:
pass
def save_state():
disk_state = {"workspaces": [], "agents": {}, "calls": []}
# LOCK INVARIANT (W17):
# lock_f (state_file + .lock) acquired at entry is deliberately NOT closed inside save_state().
# This exclusive global process lock is the LOAD-BEARING guarantee of consistency for
# overwriting disk_state["agents"] and disk_state["workspaces"].
# If lock scope is ever narrowed or made fine-grained, state merging MUST switch to
# explicit key-by-key dictionary diff merging first before removing the global lock.
disk_state = {"workspaces": [], "agents": {}, "calls": [], "panes": []}
if os.path.exists(state_file):
for _retry in range(50):
try:
@@ -120,8 +134,18 @@ def save_state():
pass
time.sleep(0.02)
disk_state["agents"] = state.get("agents", {})
state["agents"] = disk_state["agents"]
if "workspaces" in state:
disk_state["workspaces"] = state["workspaces"]
disk_ws = disk_state.setdefault("workspaces", [])
for w in state["workspaces"]:
if not any(dw.get("workspace_id") == w.get("workspace_id") for dw in disk_ws):
disk_ws.append(w)
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):
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:]):
disk_calls.append(sys.argv[1:])
@@ -151,7 +175,11 @@ if cmd1 == "workspace":
sys.exit(0)
cmd2 = args[1]
if cmd2 == "list":
res = {"workspaces": state.get("workspaces", [])}
# W10: WorkspaceInfo has NO cwd key
wss = []
for w in state.get("workspaces", []):
wss.append({"workspace_id": w["workspace_id"], "label": w.get("label", "default")})
res = {"workspaces": wss}
print(json.dumps({"result": res}))
sys.exit(0)
elif cmd2 == "create":
@@ -168,11 +196,135 @@ if cmd1 == "workspace":
else:
i += 1
workspaces = state.get("workspaces", [])
if not any(w["label"] == label for w in workspaces):
ws_id = f"w{len(workspaces) + 1}"
workspaces.append({"workspace_id": ws_id, "label": label, "cwd": cwd})
state["workspaces"] = workspaces
ws_id = f"w{len(workspaces) + 1}"
workspaces.append({"workspace_id": ws_id, "label": label, "cwd": cwd})
state["workspaces"] = workspaces
# W10: workspace create returns full workspace_created object
root_pane_id = f"{ws_id}:p1"
panes = state.get("panes", [])
panes.append({"pane_id": root_pane_id, "workspace_id": ws_id, "cwd": cwd, "tab_id": f"{ws_id}:t1"})
state["panes"] = panes
save_state()
print(json.dumps({
"result": {
"type": "workspace_created",
"workspace": {"workspace_id": ws_id, "label": label},
"tab": {"tab_id": f"{ws_id}:t1"},
"root_pane": {"pane_id": root_pane_id}
}
}))
sys.exit(0)
elif cmd1 == "pane":
if len(args) < 2:
sys.exit(0)
cmd2 = args[1]
if cmd2 == "list":
target_ws = ""
i = 2
while i < len(args):
if args[i] == "--workspace":
target_ws = args[i+1]
i += 2
else:
i += 1
panes_list = []
# Build panes from agents or state["panes"]
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"),
"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)
print(json.dumps({"result": {"panes": panes_list}}))
sys.exit(0)
elif cmd2 == "split":
# W11: pane split handler
print(json.dumps({"result": {"type": "pane_split", "pane": {"pane_id": "w1:p_split"}}}))
sys.exit(0)
elif cmd2 == "layout":
# W11: pane layout handler returning area, panes, focused_pane_id
focused = "w1:p1"
panes_list = []
agents = state.get("agents", {})
count = max(len(agents), 1)
w_per_pane = 184 // count
idx = 0
for name, data in agents.items():
panes_list.append({
"pane_id": data.get("pane_id", f"w1:p{idx+1}"),
"rect": {"width": w_per_pane, "height": 78}
})
idx += 1
if not panes_list:
panes_list = [{"pane_id": "w1:p1", "rect": {"width": 184, "height": 78}}]
print(json.dumps({
"result": {
"area": {"width": 184, "height": 78},
"focused_pane_id": panes_list[0]["pane_id"],
"panes": panes_list
}
}))
sys.exit(0)
elif cmd2 == "send-keys":
if len(args) < 4:
sys.exit(1)
name = args[2]
key = args[3]
agents = state.get("agents", {})
actual_name = name
for a_name, data in agents.items():
if data.get("pane_id") == name or a_name == name:
actual_name = a_name
break
if actual_name in agents:
agents[actual_name]["sent_keys"] = agents[actual_name].get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
agents[actual_name]["buffer"] = agents[actual_name].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.exit(1)
elif cmd2 == "process-info":
pane_id = ""
if "--pane" in args:
pane_id = args[args.index("--pane") + 1]
pid = 9999
for name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
pid = data.get("pid", 9999)
break
res = {
"process_info": {
"foreground_processes": [{"pid": pid}],
"shell_pid": pid
}
}
print(json.dumps({"result": res}))
sys.exit(0)
elif cmd2 == "close":
pane_id = args[2]
agents = state.get("agents", {})
to_delete = []
for name, data in agents.items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1" or name == pane_id:
to_delete.append(name)
for name in to_delete:
del agents[name]
state["agents"] = agents
save_state()
sys.exit(0)
elif cmd1 == "agent":
@@ -207,22 +359,36 @@ elif cmd1 == "agent":
agent_cmd = []
opts = args[3:]
# W8: Whitelist herdr 0.7.4 allowed flags
whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"}
i = 0
unknown_flags = []
while i < len(opts):
if opts[i] == "--workspace":
opt = opts[i]
if opt not in whitelist:
unknown_flags.append(opt)
i += 1
continue
if opt == "--workspace":
ws = opts[i+1]
i += 2
elif opts[i] == "--cwd":
elif opt == "--cwd":
cwd = opts[i+1]
i += 2
elif opts[i] == "--env":
elif opt == "--env":
env_val = opts[i+1]
if "=" in env_val:
k, v = env_val.split("=", 1)
os.environ[k] = v
i += 2
elif opt in ("--split", "--tab"):
i += 2
else:
i += 1
if unknown_flags:
print(f"usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...> (unknown flag: {unknown_flags[0]})")
sys.exit(0)
# Determine the agent type (claude, agy, hermes, cline)
agent_type = "claude"
@@ -344,13 +510,24 @@ elif cmd1 == "agent":
state["agents"] = agents
save_state()
print(json.dumps({
"result": {
"type": "agent_started",
"agent": {
"name": name,
"workspace_id": ws or "w1",
"pane_id": f"w1:p_{name}",
"cwd": cwd or "TMP_PATH_PLACEHOLDER"
},
"argv": agent_cmd
}
}))
sys.exit(0)
elif cmd2 == "get":
if len(args) < 3:
sys.exit(1)
name = args[2]
agents = state.get("agents", {})
sys.stderr.write(f"[mock_herdr] agent get '{name}' — known agents: {list(agents.keys())}\\n")
if name in agents:
agent_data = agents[name]
pane_info = {
@@ -396,9 +573,9 @@ elif cmd1 == "agent":
if name in agents:
agents[name]["sent_text"] = agents[name].get("sent_text", "") + text
if text in ("C-m", "Enter"):
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\nesc to interrupt"
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
else:
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\n" + text
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\\\n" + text
if "/exit" in text or "exit" in text or "Exit" in text:
agents[name]["status"] = "stopped"
state["agents"] = agents
@@ -441,59 +618,53 @@ elif cmd1 == "session":
else:
sys.exit(1)
elif cmd1 == "pane":
if len(args) < 3:
sys.exit(1)
cmd2 = args[1]
if cmd2 == "send-keys":
if len(args) < 4:
sys.exit(1)
name = args[2]
key = args[3]
agents = state.get("agents", {})
actual_name = name
for a_name, data in agents.items():
if data.get("pane_id") == name:
actual_name = a_name
break
if actual_name in agents:
agents[actual_name]["sent_keys"] = agents[actual_name].get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
agents[actual_name]["buffer"] = agents[actual_name].get("buffer", "") + "\\nesc to interrupt"
state["agents"] = agents
save_state()
sys.exit(0)
elif cmd1 == "send-keys":
target = ""
key = ""
i = 1
while i < len(args):
if args[i] in ("-t", "--target") and i + 1 < len(args):
target = args[i+1]
i += 2
else:
sys.exit(1)
elif cmd2 == "process-info":
pane_id = ""
if "--pane" in args:
pane_id = args[args.index("--pane") + 1]
pid = 9999
for name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
pid = data.get("pid", 9999)
break
res = {
"process_info": {
"foreground_processes": [{"pid": pid}],
"shell_pid": pid
}
}
print(json.dumps({"result": res}))
sys.exit(0)
elif cmd2 == "close":
pane_id = args[2]
agents = state.get("agents", {})
to_delete = []
for name, data in agents.items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
to_delete.append(name)
for name in to_delete:
del agents[name]
key = args[i]
i += 1
agents = state.get("agents", {})
actual_name = target
for a_name, data in agents.items():
if data.get("pane_id") == target or a_name == target:
actual_name = a_name
break
if actual_name in agents:
agents[actual_name]["sent_keys"] = agents[actual_name].get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
agents[actual_name]["buffer"] = agents[actual_name].get("buffer", "") + "\\\\n\\\\nesc to interrupt"
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.exit(0)
elif cmd1 == "capture-pane":
target = ""
i = 1
while i < len(args):
if args[i] in ("-t", "--target") and i + 1 < len(args):
target = args[i+1]
i += 2
else:
i += 1
agents = state.get("agents", {})
actual_name = target
for a_name, data in agents.items():
if data.get("pane_id") == target or a_name == target:
actual_name = a_name
break
if actual_name in agents:
print(agents[actual_name].get("buffer", "Ready"))
else:
print("Ready")
sys.exit(0)
elif cmd1 == "list-panes":
session_target = ""
@@ -505,7 +676,15 @@ elif cmd1 == "list-panes":
pid = data.get("pid", 9999)
cwd = data.get("cwd", "TMP_PATH_PLACEHOLDER")
cmd = data.get("command", "claude")
print(f"{pid}|{cwd}|{cmd}")
if "-F" in args:
f_idx = args.index("-F")
fmt = args[f_idx + 1] if f_idx + 1 < len(args) else ""
if "pane_current_path" in fmt or "|" in fmt:
print(f"{pid}|{cwd}|{cmd}")
else:
print(f"{pid}")
else:
print(f"{pid}|{cwd}|{cmd}")
sys.exit(0)
else:
sys.exit(1)
@@ -522,6 +701,19 @@ elif cmd1 == "has-session":
else:
sys.exit(1)
elif cmd1 == "kill-session":
sess_target = ""
if "-t" in args:
sess_target = args[args.index("-t") + 1]
elif len(args) > 1:
sess_target = args[1]
agents = state.get("agents", {})
if sess_target in agents:
del agents[sess_target]
state["agents"] = agents
save_state()
sys.exit(0)
elif cmd1 == "ls":
if "-F" in args:
# Real POSIX seconds, not a sentinel: a below-floor value silently