fix(lib): finalize herdr 0.7.4 contract refactor and layout policy
This commit is contained in:
+255
-63
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user