Profiles: machines, roles and shared layers

The repo was one machine's capture. It is now a set of profiles built from
layers under profiles/:

  machines/  hp-laptop, macbook-air-m2, coder-desktop
  roles/     developer, devops, ai-agents
  features/  handoff (lan-mouse)
  shared     base, personal, personal-desktop

A profile resolves to its includes (depth first), any --roles, then itself;
for a path several layers provide, the last wins. install.sh --show prints
the layers and which one owns each file.

Hyprland settings no longer share a file: base's hyprland.lua loads
~/.config/hypr/layers/*.lua with Omarchy's own require_all, and bindings.lua
stays stock so nothing binds twice. Verified with Hyprland 0.56.2
--verify-config for all three profiles, plus a broken probe layer to prove
the directory is loaded.

packages.txt gains per-line arch (Arch Linux ARM lacks opencode, codex, mise,
shellcheck) and cmd= (skip when dotfiles' ~/.local/bin already provides the
command), and an [ignore] section that replaces capture.sh's hard-coded list.
Tab-separated records would have collapsed empty fields under read, so
package records use the unit separator.

hp-laptop: re-captured. monitors.lua catch-all scale is now 1, shell.json
has four new plugins, fix-stuck-displays has its connector-name fix,
navbar-cat is gone, plugin-manager and omarchy-phone are pinned, the
herdr-hud working tree is re-diffed and bin/herdr-web rides along. Its
handoff peer is now the Air (192.168.1.49); the old default list was the
HP's own addresses, written from the Air's side.

macbook-air-m2: captured read-only over SSH as it is (no themes, plugins
or bar changes). Its LAN_MOUSE_POINTER_SCALE=0.7 moves from the shared unit
into a lan-mouse.service.d drop-in in its own layer.

coder-desktop: the Omarchy settings covert-cloud's image used to patch in
by hand (Virtual-1 at 1920x1080, no animations, stay-awake, Retro 82 once),
for the image to apply at a pinned commit. Roles reach it per workspace via
install.sh --roles ... --no-clobber.

capture.sh writes each file back into the layer that owns it, and refuses
to run before the first layered install unless --force, since the old file
shapes would land in shared layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153egowV1Xw2RNjZnSyZicc
This commit is contained in:
2026-10-16 16:01:54 -04:00
co-authored by Claude Opus 5.5
parent b3f3f46b98
commit 87a31858fd
98 changed files with 2010 additions and 532 deletions
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Start and close Herdr agents for the HUD. herdr-monitor stays read-only."""
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
TIMEOUT = 15
# Herdr's supported kinds (`herdr agent start --kind`), with display names.
KINDS = {
"claude": "Claude Code", "codex": "Codex", "gemini": "Gemini CLI", "cursor": "Cursor",
"opencode": "opencode", "copilot": "Copilot CLI", "pi": "pi", "omp": "omp", "grok": "Grok",
"hermes": "Hermes", "amp": "Amp", "droid": "Droid", "cline": "Cline", "kimi": "Kimi",
"kiro": "Kiro", "qwen": "Qwen Code", "kilo": "Kilo", "devin": "Devin", "agy": "Antigravity",
"mastracode": "Mastra Code", "qodercli": "Qoder", "maki": "Maki",
}
EXECUTABLE = {"cursor": "cursor-agent"}
class AgentsError(RuntimeError):
def __init__(self, message: str, code: str = ""):
super().__init__(message)
self.code = code
def herdr(*args: str, timeout: float = TIMEOUT) -> dict:
executable = shutil.which("herdr")
if not executable:
raise AgentsError("Herdr is not installed or not on PATH")
try:
done = subprocess.run([executable, *args], capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as error:
raise AgentsError("Herdr did not respond in time") from error
if done.returncode != 0:
# Herdr prints {"error": {"code", "message"}} on stderr.
try:
error = json.loads(done.stderr).get("error") or {}
except (json.JSONDecodeError, AttributeError):
error = {}
raise AgentsError(error.get("message") or done.stderr.strip() or f"herdr {args[0]} failed",
error.get("code", ""))
return json.loads(done.stdout or "{}").get("result", {})
def installed_kinds() -> list[str]:
# Agents run in Herdr's login shell, which may have PATH entries (mise, npm)
# that the desktop shell lacks, so ask that shell.
executables = [EXECUTABLE.get(kind, kind) for kind in KINDS]
script = "for e in " + " ".join(executables) + '; do command -v "$e" >/dev/null 2>&1 && echo "$e"; done'
try:
done = subprocess.run([os.environ.get("SHELL", "/bin/bash"), "-lic", script],
capture_output=True, text=True, timeout=TIMEOUT, stdin=subprocess.DEVNULL)
found = set(done.stdout.split())
except subprocess.TimeoutExpired:
found = {e for e in executables if shutil.which(e)}
return [kind for kind in KINDS if EXECUTABLE.get(kind, kind) in found]
def options() -> dict:
agents = herdr("agent", "list").get("agents", [])
spaces = herdr("workspace", "list").get("workspaces", [])
folders = {str(a.get("cwd")) for a in agents if a.get("cwd")}
work = Path.home() / "Work"
if work.is_dir():
folders |= {str(p) for p in work.iterdir() if p.is_dir() and not p.name.startswith(".")}
# A workspace's folder is where its first agent runs; used to pick a default.
space_cwd = {}
for agent in agents:
space_cwd.setdefault(str(agent.get("workspace_id", "")), agent.get("cwd"))
return {
"kinds": [{"kind": kind, "label": KINDS[kind]} for kind in installed_kinds()],
"folders": sorted(folders),
"workspaces": [{"id": s.get("workspace_id"), "label": s.get("label") or s.get("workspace_id"),
"cwd": space_cwd.get(str(s.get("workspace_id")))} for s in spaces],
}
def slug(text: str, fallback: str) -> str:
# Herdr agent names: [a-z][a-z0-9_-]{0,31}, unique among live agents.
base = re.sub(r"[^a-z0-9_-]+", "-", text.lower())
base = re.sub(r"^[^a-z]+|-+$", "", base)[:24]
return base or fallback
def start(request: dict) -> dict:
kind = str(request.get("kind", ""))
if kind not in installed_kinds():
raise AgentsError(f"{KINDS.get(kind, kind)} isn't installed")
home = Path.home().resolve()
folder = Path(os.path.expanduser(str(request.get("cwd", "")).strip())).resolve()
if not folder.is_dir():
raise AgentsError("That folder doesn't exist")
if folder != home and home not in folder.parents:
raise AgentsError("Pick a folder in your home directory")
name = str(request.get("name", "")).strip()[:60]
prompt = str(request.get("prompt", "")).strip()
workspace = str(request.get("workspace", "") or "")
label = (name or folder.name)[:24]
if workspace:
created = herdr("tab", "create", "--workspace", workspace, "--cwd", str(folder), "--label", label)
else:
created = herdr("workspace", "create", "--cwd", str(folder), "--label", folder.name, "--no-focus")
herdr("tab", "rename", created["tab"]["tab_id"], label)
pane = created["root_pane"]["pane_id"]
tab = created["tab"]["tab_id"]
taken = {a.get("name") for a in herdr("agent", "list").get("agents", [])}
base = slug(name or folder.name, kind)
agent_name, n = base, 2
while agent_name in taken:
agent_name, n = f"{base[:28]}-{n}", n + 1
# Claude shows --name as its terminal title and session name.
args = ["--", "--name", name] if kind == "claude" and name else []
time.sleep(0.8) # let the shell reach its prompt
try:
herdr("agent", "start", agent_name, "--kind", kind, "--pane", pane, "--timeout", "45000", *args, timeout=50)
except AgentsError as error:
# Blocked at startup (a trust-this-folder question, say) still counts:
# the HUD terminal can answer it. Otherwise close the empty tab.
if error.code == "agent_not_ready":
return {"pane_id": pane, "blocked": True}
try:
herdr("tab", "close", tab)
except AgentsError:
pass
raise AgentsError(f"{KINDS[kind]} didn't start: {error}") from None
if prompt:
# Herdr reports the start a moment before it takes prompts for the name.
for attempt in range(20):
try:
herdr("agent", "prompt", pane, prompt)
break
except AgentsError as error:
if error.code != "agent_not_ready" or attempt == 19:
return {"pane_id": pane, "blocked": False, "prompt_error": str(error)}
time.sleep(0.5)
return {"pane_id": pane, "blocked": False}
def claude_attach_id(pane: str) -> str:
info = herdr("pane", "process-info", "--pane", pane).get("process_info", {})
for process in info.get("foreground_processes", []):
argv = process.get("argv") or []
if len(argv) >= 3 and os.path.basename(argv[0]) == "claude" and argv[1] == "attach":
return argv[2]
return ""
def close(pane: str) -> dict:
# A `claude attach` pane only views a background session; closing the pane
# would leave the session running, so stop it (its conversation is kept).
session = ""
try:
session = claude_attach_id(pane)
except AgentsError:
pass
if session:
claude = shutil.which("claude") or str(Path.home() / ".local/share/mise/shims/claude")
done = subprocess.run([claude, "stop", session], capture_output=True, text=True, timeout=TIMEOUT)
if done.returncode != 0:
raise AgentsError((done.stderr or done.stdout).strip().splitlines()[0] if (done.stderr or done.stdout).strip()
else "claude stop failed")
herdr("pane", "close", pane)
return {"closed": pane, "stopped_session": session}
def main(argv: list[str]) -> int:
if argv[1:] == ["options"]:
result = options()
elif len(argv) == 3 and argv[1] == "start":
result = start(json.loads(argv[2]))
elif len(argv) == 3 and argv[1] == "close":
result = close(argv[2])
else:
raise AgentsError("usage: herdr-agents options|start JSON|close PANE")
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv))
except (AgentsError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
print(str(error)[:4096], file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Show a Herdr pane in the herdr web ui (devswha.herdr-web-ui) as one floating app window.
The shell cannot host a web view (QtWebEngine aborts inside Quickshell), so the HUD
hands agents off to a browser app window instead. The HUD keeps a single window:
asking for the pane it already shows brings it forward; asking for another pane
replaces it in the same place and size. Failures go to a desktop notification,
since the HUD has closed by then.
"""
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.parse import quote, urlsplit
HOME = Path.home()
WEB_ENV = HOME / ".config/herdr/plugins/config/devswha.herdr-web-ui/env"
CONFIG = HOME / ".config/herdr-hud-native/web.json"
STATE = HOME / ".config/herdr-hud-native/web-window.json"
WAIT_SECONDS = 10
class WebError(RuntimeError):
pass
def base_url() -> str:
"""HERDR_HUD_WEB_URL, else {"url": ...} in web.json, else the web ui's own listen address."""
override = os.environ.get("HERDR_HUD_WEB_URL", "").strip()
if not override:
try:
override = str(json.loads(CONFIG.read_text()).get("url") or "").strip()
except (OSError, ValueError, AttributeError):
override = ""
if override:
return override.rstrip("/")
values = {}
try:
for line in WEB_ENV.read_text().splitlines():
key, sep, value = line.partition("=")
if sep and key.strip() in ("HOST", "PORT"):
values[key.strip()] = value.strip().strip("'\"")
except OSError:
pass
host = values.get("HOST") or "127.0.0.1"
if host in ("0.0.0.0", "::", "[::]"):
host = "127.0.0.1"
return f"http://{host}:{values.get('PORT') or '7317'}"
def hyprctl(*args: str) -> str:
done = subprocess.run(["hyprctl", *args], capture_output=True, text=True, timeout=5)
if done.returncode != 0 or done.stdout.startswith("Couldn't connect"):
raise WebError(done.stdout.strip() or done.stderr.strip() or "hyprctl failed")
return done.stdout
def fix_hyprland_signature() -> None:
"""A shell that outlived a Hyprland restart keeps a dead signature; use the live one."""
try:
hyprctl("version")
return
except WebError:
pass
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "hypr"
for candidate in sorted(runtime.glob("*/.socket.sock"), key=lambda p: p.stat().st_mtime, reverse=True):
os.environ["HYPRLAND_INSTANCE_SIGNATURE"] = candidate.parent.name
try:
hyprctl("version")
return
except WebError:
continue
raise WebError("Cannot reach Hyprland")
def dispatch(expression: str) -> None:
out = hyprctl("dispatch", expression).strip()
if out != "ok":
raise WebError(out)
def clients() -> list:
return json.loads(hyprctl("clients", "-j"))
def web_windows(host: str) -> dict:
# Chromium-family app windows are classed "<browser>-<host>__<path>-<profile>".
marker = f"-{host}__"
return {c["address"]: c for c in clients() if marker in str(c.get("class", ""))}
def load_state() -> dict:
try:
state = json.loads(STATE.read_text())
return state if isinstance(state, dict) else {}
except (OSError, ValueError):
return {}
def save_state(state: dict) -> None:
STATE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2) + "\n")
tmp.replace(STATE)
def active_workspace() -> str:
return str(json.loads(hyprctl("activeworkspace", "-j")).get("id", ""))
def bring_forward(address: str) -> None:
window = f"address:{address}"
workspace = active_workspace()
if workspace:
dispatch(f"hl.dsp.window.move({{ workspace = '{workspace}', window = '{window}' }})")
dispatch(f"hl.dsp.focus({{ window = '{window}' }})")
def default_size() -> tuple:
for monitor in json.loads(hyprctl("monitors", "-j")):
if monitor.get("focused"):
scale = float(monitor.get("scale") or 1)
width, height = monitor["width"] / scale, monitor["height"] / scale
return round(width * 0.72), round(height * 0.82)
return 1280, 860
def place(address: str, previous: dict | None) -> None:
window = f"address:{address}"
dispatch(f"hl.dsp.window.float({{ action = 'enable', window = '{window}' }})")
if previous and previous.get("floating"):
width, height = previous["size"]
x, y = previous["at"]
dispatch(f"hl.dsp.window.resize({{ x = {width}, y = {height}, window = '{window}' }})")
dispatch(f"hl.dsp.window.move({{ x = {x}, y = {y}, window = '{window}' }})")
else:
width, height = default_size()
dispatch(f"hl.dsp.window.resize({{ x = {width}, y = {height}, window = '{window}' }})")
dispatch(f"hl.dsp.window.center({{ window = '{window}' }})")
dispatch(f"hl.dsp.focus({{ window = '{window}' }})")
def open_pane(pane: str) -> None:
base = base_url()
host = urlsplit(base).hostname or ""
if not host:
raise WebError(f"Bad web ui address: {base}")
fix_hyprland_signature()
existing = web_windows(host)
state = load_state()
tracked = existing.get(str(state.get("address", "")))
if tracked and state.get("pane") == pane:
bring_forward(tracked["address"])
return
if tracked:
dispatch(f"hl.dsp.window.close({{ window = 'address:{tracked['address']}' }})")
url = f"{base}/?pane={quote(pane, safe='')}" if pane else f"{base}/"
subprocess.Popen(["omarchy-launch-webapp", url], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
deadline = time.monotonic() + WAIT_SECONDS
while time.monotonic() < deadline:
time.sleep(0.15)
fresh = [a for a in web_windows(host) if a not in existing]
if fresh:
place(fresh[0], tracked)
save_state({"address": fresh[0], "pane": pane})
return
raise WebError("The web ui window did not appear")
def main() -> int:
if len(sys.argv) != 3 or sys.argv[1] != "open":
print("usage: herdr-web open PANE", file=sys.stderr)
return 2
try:
open_pane(sys.argv[2])
except (WebError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
subprocess.run(["notify-send", "Herdr HUD", f"Cannot open the web ui: {error}"], check=False)
print(error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff