Capture this laptop's Omarchy customizations

Everything on top of a stock Omarchy 4.0.4-1 install, gathered so a second
machine can be brought to the same state: the ~/.config files that actually
differ from the shipped defaults, the three hand-built themes, the ten shell
plugins pinned to commits, the two custom scripts, two user units, and the
/etc drop-ins.

install.sh applies it all idempotently, backing up anything it overwrites.
capture.sh runs the other way so the repo stays the source of truth rather
than a snapshot that rots.

indie.herdr-hud's ten local-only commits ship as a git bundle plus a
working-tree patch, since they stay off the upstream PR deliberately.

Secrets, the dotfiles repo's territory (shell/git/mise), the remote-access
stack and aether-managed themes are all scoped out; README says why for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 12:07:15 -04:00
co-authored by Claude Opus 5
commit 9ac198da26
61 changed files with 3279 additions and 0 deletions
+192
View File
@@ -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,861 @@
diff --git i/HerdrHud.qml w/HerdrHud.qml
index 018311d..d01449c 100644
--- i/HerdrHud.qml
+++ w/HerdrHud.qml
@@ -7,6 +7,7 @@ import Quickshell.Wayland
import qs.Commons
import qs.Ui as Ui
import "Alerts.js" as Alerts
+import "Roster.js" as Roster
import "."
Item {
@@ -21,12 +22,13 @@ Item {
readonly property string pluginId: (manifest && manifest.id) || "indie.herdr-hud"
readonly property string pluginDir: (manifest && manifest.__sourceDir) || ""
readonly property string bridgePath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-monitor")).replace(/^file:\/\//, ""))
+ readonly property string agentsPath: decodeURIComponent(String(Qt.resolvedUrl("bin/herdr-agents")).replace(/^file:\/\//, ""))
readonly property string homeDir: Quickshell.env("HOME")
readonly property string configDir: homeDir + "/.config/herdr-hud-native"
readonly property string statePath: configDir + "/state.json"
property bool opened: false
- onOpenedChanged: if (opened) clearAlerts()
+ onOpenedChanged: if (opened) { clearAlerts(); resort() }
property bool overlayVisible: true
property bool openingRequested: false
property bool demoMode: false
@@ -35,10 +37,136 @@ Item {
property var agents: []
property string selectedPane: ""
property var unread: ({})
- // Keep Herdr's own order (workspace order). Re-sorting by attention moved
- // agents as soon as they were selected or changed status, which sent
- // prompts to the wrong agent; status glyphs and badges show attention.
- readonly property var sortedAgents: agents
+ // Herdr's own order (workspace order) by default. Re-sorting live by
+ // attention moved agents as soon as they were selected or changed status,
+ // which sent prompts to the wrong agent. So other sort orders are worked out
+ // only when the panel opens, the sort changes, or agents come or go; a row
+ // never moves while you look at it. Status glyphs and badges show attention.
+ property string sortMode: "herdr"
+ property var rosterOrder: []
+ property var activity: ({})
+ readonly property var sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)
+
+ function resort() { rosterOrder = Roster.order(agents, sortMode, activity) }
+
+ function cycleSort(step) {
+ var index = Roster.SORTS.indexOf(sortMode)
+ sortMode = Roster.SORTS[(index + step + Roster.SORTS.length) % Roster.SORTS.length]
+ resort()
+ saveState()
+ }
+
+ // Starting and closing agents goes through bin/herdr-agents.
+ property bool newAgentOpen: false
+ property var spawnOptions: null
+ property string newKind: "claude"
+ property string newFolder: ""
+ property string newWorkspace: ""
+ property bool newAgentBusy: false
+ property string newAgentError: ""
+ property string lastKind: ""
+ property string lastFolder: ""
+ property string pendingPane: ""
+ property string confirmClosePane: ""
+ property string closingPane: ""
+
+ function tildePath(path) {
+ return homeDir && String(path).indexOf(homeDir) === 0 ? "~" + String(path).slice(homeDir.length) : String(path)
+ }
+
+ function expandPath(path) {
+ var text = String(path || "").trim()
+ return text === "~" || text.indexOf("~/") === 0 ? homeDir + text.slice(1) : text
+ }
+
+ // IPC entry point too: omarchy-shell shell call indie.herdr-hud newAgent '{}'
+ function newAgent(_arg) {
+ if (!(opened && overlayVisible)) open("{}")
+ openNewAgent()
+ }
+
+ function openNewAgent() {
+ if (demoMode || optionsProc.running) return
+ newAgentOpen = true
+ newAgentError = ""
+ spawnOptions = null
+ optionsProc.exec(["python3", agentsPath, "options"])
+ }
+
+ function applySpawnOptions(raw, error, exitCode) {
+ if (exitCode !== 0) {
+ newAgentError = String(error || "Couldn't list the agents you can start.").trim()
+ return
+ }
+ try {
+ var options = JSON.parse(String(raw || "{}"))
+ var kinds = options.kinds || []
+ newKind = kinds.some(function(k) { return k.kind === lastKind }) ? lastKind
+ : kinds.some(function(k) { return k.kind === "claude" }) ? "claude" : (kinds.length ? kinds[0].kind : "")
+ // Pick the folder before publishing the options; the form fills in from both.
+ setNewFolder(lastFolder || (options.folders || [])[0] || homeDir, options)
+ spawnOptions = options
+ } catch (parseError) {
+ newAgentError = "Herdr returned an unreadable response."
+ }
+ }
+
+ // Default to the workspace already working in that folder, else a new one.
+ function setNewFolder(path, options) {
+ newFolder = path
+ var dir = expandPath(path)
+ var known = options || spawnOptions
+ var match = (known ? known.workspaces : []).find(function(w) { return w.cwd === dir })
+ newWorkspace = match ? String(match.id) : ""
+ }
+
+ function folderSuggestions(text) {
+ if (!spawnOptions) return []
+ var dir = expandPath(text)
+ var query = dir.toLowerCase()
+ return spawnOptions.folders.filter(function(f) { return f !== dir && f.toLowerCase().indexOf(query) >= 0 }).slice(0, 6)
+ }
+
+ function startAgent(name, prompt) {
+ if (agentsProc.running || !newKind) return
+ if (!expandPath(newFolder)) { newAgentError = "Pick a folder."; return }
+ newAgentBusy = true
+ newAgentError = ""
+ lastKind = newKind
+ lastFolder = expandPath(newFolder)
+ saveState()
+ agentsProc.action = "start"
+ agentsProc.exec(["python3", agentsPath, "start", JSON.stringify({
+ kind: newKind, cwd: expandPath(newFolder), workspace: newWorkspace, name: String(name || ""), prompt: String(prompt || "")
+ })])
+ }
+
+ function closeAgent(pane) {
+ if (agentsProc.running) return
+ confirmClosePane = ""
+ closingPane = pane
+ agentsProc.action = "close"
+ agentsProc.exec(["python3", agentsPath, "close", pane])
+ }
+
+ function applyAgentsAction(action, raw, error, exitCode) {
+ var result = ({})
+ try { result = JSON.parse(String(raw || "{}")) } catch (parseError) { result = ({}) }
+ var message = String(error || "").trim()
+ if (action === "start") {
+ newAgentBusy = false
+ if (exitCode !== 0) { newAgentError = message || "The agent didn't start."; return }
+ newAgentOpen = false
+ pendingPane = String(result.pane_id || "")
+ noticeText = result.blocked ? "It's asking something before it starts. Answer it in the terminal."
+ : result.prompt_error ? "Started, but the first message didn't go through: " + result.prompt_error : ""
+ } else {
+ closingPane = ""
+ if (exitCode !== 0) noticeText = message || "Couldn't close that agent."
+ else noticeText = result.stopped_session ? "Stopped the Claude background session. Its conversation is kept." : ""
+ }
+ refreshRoster()
+ }
property var lastSequence: ({})
property var alertQueue: []
property bool alertsEnabled: true
@@ -190,6 +318,9 @@ Item {
if (Number(parsed.rosterWidth) > 0) rosterWidth = clamp(Number(parsed.rosterWidth), 150, 330)
if (Number(parsed.panelWidth) > 0) panelWidth = Math.max(minPanelWidth, Number(parsed.panelWidth))
if (Number(parsed.panelHeight) > 0) panelHeight = Math.max(minPanelHeight, Number(parsed.panelHeight))
+ if (Roster.SORTS.indexOf(parsed.sortMode) >= 0) sortMode = parsed.sortMode
+ if (typeof parsed.lastKind === "string") lastKind = parsed.lastKind
+ if (typeof parsed.lastFolder === "string") lastFolder = parsed.lastFolder
}
} catch (error) {
positions = ({})
@@ -209,7 +340,10 @@ Item {
positions: positions,
rosterWidth: Math.round(rosterWidth),
panelWidth: Math.round(panelWidth),
- panelHeight: Math.round(panelHeight)
+ panelHeight: Math.round(panelHeight),
+ sortMode: sortMode,
+ lastKind: lastKind,
+ lastFolder: lastFolder
}, null, 2) + "\n")
}
@@ -486,9 +620,10 @@ Item {
return muted
}
- function agentName(agent) {
- if (!agent) return "Agent"
- var type = String(agent.agent || "agent")
+ function agentName(agent) { return Roster.displayName(agent) }
+
+ function agentKind(agent) {
+ var type = String(agent ? agent.agent || "agent" : "agent")
return type.charAt(0).toUpperCase() + type.slice(1)
}
@@ -581,6 +716,7 @@ Item {
var nextUnread = cloneObject(unread)
var nextSequence = ({})
var nextWorkingSince = ({})
+ var nextActivity = ({})
var live = ({})
var previousByPane = ({})
for (var p = 0; p < agents.length; p++) previousByPane[String(agents[p].pane_id || "")] = agents[p]
@@ -595,13 +731,20 @@ Item {
nextWorkingSince[identity] = workingSince[identity] || Date.now()
}
live[pane] = true
+ // Last activity: the session store's last write, else the last status change we saw.
+ var statusChanged = !!previousByPane[pane] && String(previousByPane[pane].agent_status || "") !== status
+ nextActivity[pane] = Math.max(Number(row.active_at) || 0, activity[pane] || 0, statusChanged ? Date.now() : 0)
nextSequence[pane] = sequence === null ? (previousSequence === undefined ? 0 : previousSequence) : sequence
if (status === "working" || (opened && pane === selectedPane)) delete nextUnread[pane]
else if (isUnreadTransition(previousByPane[pane], row, previousSequence, sequence)) nextUnread[pane] = true
}
for (var unreadPane in nextUnread) if (!live[unreadPane]) delete nextUnread[unreadPane]
var previousAgent = agentForPane(selectedPane)
+ var paneSetChanged = rows.length !== agents.length
+ || rows.some(function(row) { return !previousByPane[String(row.pane_id || "")] })
agents = rows
+ activity = nextActivity
+ if (!opened || paneSetChanged) resort()
workingSince = nextWorkingSince
activityNow = Date.now()
var currentAgent = agentForPane(selectedPane)
@@ -616,7 +759,12 @@ Item {
}
completed.forEach(function(agent) { root.queueAlert(agent) })
errorText = ""
- if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(rows[0].pane_id || "") : "")
+ if (pendingPane && live[pendingPane]) {
+ selectAgent(pendingPane)
+ pendingPane = ""
+ }
+ if (confirmClosePane && !live[confirmClosePane]) confirmClosePane = ""
+ if (!agentForPane(selectedPane)) selectAgent(rows.length ? String(sortedAgents[0].pane_id || "") : "")
dataRevision++
} catch (parseError) {
alertBaseline = false
@@ -627,6 +775,86 @@ Item {
}
}
+ // A small text button for the roster and the new-agent form.
+ component HudChip: Rectangle {
+ id: chip
+ required property var hud
+ property string text: ""
+ property bool selected: false
+ property bool primary: false
+ property bool danger: false
+ signal clicked()
+ signal rightClicked()
+ implicitWidth: chipLabel.implicitWidth + 18
+ implicitHeight: 24
+ radius: hud.cornerRadius
+ opacity: enabled ? 1 : 0.5
+ readonly property color tone: danger ? hud.urgent : hud.accent
+ color: primary ? tone : selected ? hud.alpha(tone, 0.22)
+ : hud.alpha(hud.foreground, chipMouse.containsMouse ? 0.1 : 0.04)
+ border.width: 1
+ border.color: primary || selected || chipMouse.containsMouse ? tone : hud.alpha(hud.foreground, 0.16)
+ Text {
+ id: chipLabel
+ anchors.centerIn: parent
+ text: chip.text
+ color: chip.primary ? hud.contrastColor(chip.tone, hud.foreground, hud.background) : chip.selected || chip.danger ? chip.tone : hud.foreground
+ font.family: hud.chromeFont
+ font.pixelSize: 11
+ font.bold: chip.selected || chip.primary
+ }
+ MouseArea {
+ id: chipMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: function(mouse) { mouse.button === Qt.RightButton ? chip.rightClicked() : chip.clicked() }
+ }
+ }
+
+ component HudField: Rectangle {
+ id: field
+ required property var hud
+ property alias text: input.text
+ property string placeholder: ""
+ property bool multiline: false
+ signal edited()
+ signal cancelled()
+ signal submitted()
+ implicitHeight: multiline ? 70 : 30
+ radius: hud.cornerRadius
+ color: hud.alpha(hud.foreground, 0.05)
+ border.width: 1
+ border.color: input.activeFocus ? hud.accent : hud.alpha(hud.foreground, 0.18)
+ clip: true
+ function focusInput() { input.forceActiveFocus() }
+ TextEdit {
+ id: input
+ anchors.fill: parent
+ anchors.margins: 7
+ color: hud.foreground
+ selectionColor: hud.alpha(hud.accent, 0.4)
+ font.family: hud.chromeFont
+ font.pixelSize: 13
+ wrapMode: field.multiline ? TextEdit.Wrap : TextEdit.NoWrap
+ selectByMouse: true
+ onTextChanged: field.edited()
+ Keys.onEscapePressed: field.cancelled()
+ // One-line fields submit on Enter; the message field takes newlines.
+ Keys.onReturnPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
+ Keys.onEnterPressed: function(event) { if (field.multiline) event.accepted = false; else field.submitted() }
+ Text {
+ anchors.fill: parent
+ visible: !input.text && !input.activeFocus
+ text: field.placeholder
+ color: hud.muted
+ font: input.font
+ elide: Text.ElideRight
+ }
+ }
+ }
+
Component.onCompleted: {
mkdirProc.running = true
Qt.callLater(root.refreshRoster)
@@ -689,6 +917,21 @@ Item {
onExited: function(exitCode) { root.applyRoster(rosterOut.text, rosterErr.text, exitCode) }
}
+ Process {
+ id: optionsProc
+ stdout: StdioCollector { id: optionsOut; waitForEnd: true }
+ stderr: StdioCollector { id: optionsErr; waitForEnd: true }
+ onExited: function(exitCode) { root.applySpawnOptions(optionsOut.text, optionsErr.text, exitCode) }
+ }
+
+ Process {
+ id: agentsProc
+ property string action: ""
+ stdout: StdioCollector { id: agentsOut; waitForEnd: true }
+ stderr: StdioCollector { id: agentsErr; waitForEnd: true }
+ onExited: function(exitCode) { root.applyAgentsAction(action, agentsOut.text, agentsErr.text, exitCode) }
+ }
+
property string previewIdentity: ""
Process {
id: alertPreviewProc
@@ -1034,6 +1277,186 @@ Item {
Layout.fillWidth: true
Layout.fillHeight: true
+ // New agent: covers the roster and terminal until started or cancelled.
+ Rectangle {
+ id: newAgentPanel
+ anchors.fill: parent
+ z: 3
+ visible: root.newAgentOpen
+ color: root.panelFill
+ radius: root.cornerRadius
+ border.width: 1
+ border.color: root.alpha(root.foreground, 0.12)
+ onVisibleChanged: if (visible) Qt.callLater(function() { nameField.focusInput() })
+
+ Connections {
+ target: root
+ function onSpawnOptionsChanged() {
+ if (!root.spawnOptions) return
+ folderField.text = root.tildePath(root.newFolder)
+ nameField.text = ""
+ promptField.text = ""
+ }
+ }
+
+ MouseArea { anchors.fill: parent } // keep clicks off the terminal underneath
+
+ Flickable {
+ anchors.fill: parent
+ anchors.margins: 14
+ contentHeight: newAgentForm.implicitHeight
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
+
+ ColumnLayout {
+ id: newAgentForm
+ width: parent.width
+ spacing: 8
+
+ Text {
+ text: "New agent"
+ color: root.accent
+ font.family: root.chromeFont
+ font.pixelSize: 15
+ font.bold: true
+ }
+ Text {
+ Layout.fillWidth: true
+ visible: !root.spawnOptions
+ text: root.newAgentError || "Loading…"
+ color: root.newAgentError ? root.urgent : root.muted
+ wrapMode: Text.Wrap
+ font.family: root.chromeFont
+ font.pixelSize: 12
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ visible: !!root.spawnOptions
+ spacing: 8
+
+ Text { text: "AGENT"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
+ Flow {
+ Layout.fillWidth: true
+ spacing: 6
+ Repeater {
+ model: root.spawnOptions ? root.spawnOptions.kinds : []
+ delegate: HudChip {
+ required property var modelData
+ hud: root
+ text: modelData.label
+ selected: root.newKind === modelData.kind
+ onClicked: root.newKind = modelData.kind
+ }
+ }
+ }
+
+ Text { text: "FOLDER"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
+ HudField {
+ id: folderField
+ hud: root
+ Layout.fillWidth: true
+ placeholder: "~/Work/project"
+ onEdited: root.setNewFolder(text)
+ onCancelled: root.newAgentOpen = false
+ onSubmitted: nameField.focusInput()
+ }
+ Flow {
+ Layout.fillWidth: true
+ spacing: 6
+ Repeater {
+ model: root.folderSuggestions(folderField.text)
+ delegate: HudChip {
+ required property var modelData
+ hud: root
+ text: root.tildePath(modelData)
+ onClicked: {
+ root.setNewFolder(modelData)
+ folderField.text = root.tildePath(modelData)
+ }
+ }
+ }
+ }
+
+ Text { text: "WORKSPACE"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
+ Flow {
+ Layout.fillWidth: true
+ spacing: 6
+ HudChip {
+ hud: root
+ text: "New workspace"
+ selected: root.newWorkspace === ""
+ onClicked: root.newWorkspace = ""
+ }
+ Repeater {
+ model: root.spawnOptions ? root.spawnOptions.workspaces : []
+ delegate: HudChip {
+ required property var modelData
+ hud: root
+ text: String(modelData.label)
+ selected: root.newWorkspace === String(modelData.id)
+ onClicked: root.newWorkspace = String(modelData.id)
+ }
+ }
+ }
+
+ Text { text: "NAME · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
+ HudField {
+ id: nameField
+ hud: root
+ Layout.fillWidth: true
+ placeholder: "e.g. Fix login bug"
+ onCancelled: root.newAgentOpen = false
+ onSubmitted: promptField.focusInput()
+ }
+
+ Text { text: "FIRST MESSAGE · OPTIONAL"; color: root.muted; font.family: root.chromeFont; font.pixelSize: 10; font.bold: true }
+ HudField {
+ id: promptField
+ hud: root
+ Layout.fillWidth: true
+ multiline: true
+ placeholder: "What should it do?"
+ onCancelled: root.newAgentOpen = false
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: !!root.newAgentError
+ text: root.newAgentError
+ color: root.urgent
+ wrapMode: Text.Wrap
+ font.family: root.chromeFont
+ font.pixelSize: 12
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: 4
+ spacing: 8
+ Text {
+ Layout.fillWidth: true
+ text: "Opens as a HERDR tab."
+ color: root.muted
+ font.family: root.chromeFont
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ HudChip { hud: root; text: "Cancel"; onClicked: root.newAgentOpen = false }
+ HudChip {
+ hud: root
+ primary: true
+ text: root.newAgentBusy ? "Starting…" : "Start agent"
+ enabled: !root.newAgentBusy && !!root.newKind
+ onClicked: root.startAgent(nameField.text, promptField.text)
+ }
+ }
+ }
+ }
+ }
+ }
+
RowLayout {
anchors.fill: parent
spacing: 0
@@ -1051,15 +1474,36 @@ Item {
anchors.margins: 8
spacing: 8
- Text {
+ RowLayout {
Layout.fillWidth: true
- text: root.demoMode ? "HERDR · PREVIEW" : root.connected
- ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
- color: root.muted
- font.family: root.chromeFont
- font.pixelSize: 11
- font.bold: true
- leftPadding: 5
+ spacing: 6
+ Text {
+ Layout.fillWidth: true
+ text: root.demoMode ? "HERDR · PREVIEW" : root.connected
+ ? "HERDR · " + root.agents.length + " AGENTS" : "HERDR · OFFLINE"
+ color: root.muted
+ font.family: root.chromeFont
+ font.pixelSize: 11
+ font.bold: true
+ leftPadding: 5
+ elide: Text.ElideRight
+ }
+ HudChip {
+ hud: root
+ text: "+ New"
+ enabled: root.connected && !root.demoMode
+ selected: root.newAgentOpen
+ onClicked: root.newAgentOpen ? root.newAgentOpen = false : root.openNewAgent()
+ }
+ }
+
+ HudChip {
+ // Click for the next order, right-click for the previous one.
+ hud: root
+ Layout.leftMargin: 4
+ text: "Sort: " + Roster.SORT_LABELS[root.sortMode] + " ⇅"
+ onClicked: root.cycleSort(1)
+ onRightClicked: root.cycleSort(-1)
}
ListView {
@@ -1093,12 +1537,16 @@ Item {
z: 1
}
+ readonly property string pane: String(modelData.pane_id || "")
+ readonly property bool confirming: root.confirmClosePane === pane
+
MouseArea {
id: agentMouse
anchors.fill: parent
hoverEnabled: true
- onClicked: root.selectAgent(String(agentRow.modelData.pane_id || ""))
+ onClicked: root.selectAgent(agentRow.pane)
}
+ HoverHandler { id: rowHover }
RowLayout {
anchors.fill: parent
@@ -1123,7 +1571,7 @@ Item {
Text {
Layout.fillWidth: true
- text: String(agentRow.modelData.workspace_label || "Untitled space")
+ text: root.agentName(agentRow.modelData)
color: root.accent
font.family: root.chromeFont
font.pixelSize: 15
@@ -1132,32 +1580,59 @@ Item {
}
Text {
Layout.fillWidth: true
- // Short line, so the context size isn't elided in a narrow roster.
+ // Context size first, so a narrow roster elides the workspace instead.
textFormat: Text.StyledText
- text: root.escapeHtml(root.agentName(agentRow.modelData))
- + (root.contextLabel(agentRow.modelData)
- ? " · <font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
- + root.contextLabel(agentRow.modelData) + "</b></font>"
+ text: (root.contextLabel(agentRow.modelData)
+ ? "<font color=\"" + root.contextColor(agentRow.modelData) + "\"><b>"
+ + root.contextLabel(agentRow.modelData) + "</b></font> · "
: "")
+ + root.escapeHtml(String(agentRow.modelData.workspace_label || "Untitled space"))
+ + (root.agentName(agentRow.modelData) !== root.agentKind(agentRow.modelData)
+ ? " · " + root.escapeHtml(root.agentKind(agentRow.modelData)) : "")
color: root.muted
font.family: root.chromeFont
font.pixelSize: 12
elide: Text.ElideRight
}
Text {
+ visible: !agentRow.confirming
Layout.fillWidth: true
- text: (root.unread[String(agentRow.modelData.pane_id || "")] ? "Unseen update · " : "")
- + root.statusLabel(agentRow.modelData)
- color: root.unread[String(agentRow.modelData.pane_id || "")] ? root.success : root.muted
+ text: root.closingPane === agentRow.pane ? "Closing…"
+ : (root.unread[agentRow.pane] ? "Unseen update · " : "") + root.statusLabel(agentRow.modelData)
+ color: root.unread[agentRow.pane] ? root.success : root.muted
font.family: root.chromeFont
font.pixelSize: 11
elide: Text.ElideRight
}
+ RowLayout {
+ visible: agentRow.confirming
+ spacing: 6
+ HudChip { hud: root; text: "Close agent"; primary: true; danger: true; onClicked: root.closeAgent(agentRow.pane) }
+ HudChip { hud: root; text: "Keep"; onClicked: root.confirmClosePane = "" }
+ }
}
+ Text {
+ // Close, shown on hover; asks before closing.
+ visible: (rowHover.hovered || agentRow.confirming) && !root.demoMode && root.closingPane !== agentRow.pane
+ Layout.alignment: Qt.AlignTop
+ text: "\u00d7"
+ color: closeMouse.containsMouse || agentRow.confirming ? root.urgent : root.muted
+ font.family: root.chromeFont
+ font.pixelSize: 16
+ font.bold: true
+ MouseArea {
+ id: closeMouse
+ anchors.fill: parent
+ anchors.margins: -4
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.confirmClosePane = agentRow.confirming ? "" : agentRow.pane
+ }
+ }
Text {
// Matches the Alt+1-9 shortcut for this agent.
- visible: agentRow.index < 9
+ visible: agentRow.index < 9 && !rowHover.hovered && !agentRow.confirming
Layout.alignment: Qt.AlignTop
Layout.topMargin: 2
text: String(agentRow.index + 1)
diff --git i/README.md w/README.md
index 685096b..37df3e5 100644
--- i/README.md
+++ w/README.md
@@ -44,6 +44,14 @@ rm -rf ~/.config/herdr-hud-native
- Approval-dialog keyboard input and TUI rendering
- Omarchy and WoW designs
- Agent switching, launcher, alerts, hidden-panel notifications
+- Agent cards lead with the agent's name (Claude's session name, else the Herdr agent name)
+- Sort the roster by Herdr order, status, recent activity, name, or workspace (click to cycle,
+ right-click to go back). Orders other than Herdr's are worked out when the panel opens, the sort
+ changes, or agents come or go, so a row never moves while you are looking at it
+- **+ New** starts any installed agent Herdr supports in a new tab: pick the agent, folder, and
+ workspace, with an optional name and first message. Also `omarchy-shell shell call indie.herdr-hud newAgent '{}'`
+- Hover a card and click × to close its agent (asks first). A `claude attach` pane's background
+ session is stopped too, keeping its conversation
- Safe reconnect, disconnect, bounded read-only monitoring, and dependency errors
The plugin supports Omarchy only. It does not provide an HTML renderer, prompt bridge, legacy plugin migration, or independent chat history. Herdr owns terminal controller, resize, scroll, and alternate-screen behavior.
diff --git i/Roster.js w/Roster.js
index f88af7c..51ba497 100644
--- i/Roster.js
+++ w/Roster.js
@@ -13,3 +13,53 @@ function sorted(agents, unread) {
return a.priority - b.priority || a.index - b.index
}).map(function(item) { return item.agent })
}
+
+// The agent's own name: Claude's session name (its terminal title, or the
+// claude_name token herdr-web reports), else the Herdr agent name, else its kind.
+function displayName(agent) {
+ if (!agent) return "Agent"
+ var token = agent.tokens && agent.tokens.claude_name
+ if (token) return String(token)
+ var title = String(agent.terminal_title_stripped || "").trim()
+ if (agent.agent === "claude" && title && !/^[^\s@]+@[^\s:]+:/.test(title) && title !== "Claude Code") return title
+ if (agent.name) return String(agent.name)
+ var type = String(agent.agent || "agent")
+ return type.charAt(0).toUpperCase() + type.slice(1)
+}
+
+var SORTS = ["herdr", "status", "recent", "name", "workspace"]
+var SORT_LABELS = { herdr: "Herdr", status: "Status", recent: "Recent", name: "Name", workspace: "Workspace" }
+var STATUS_RANK = { blocked: 0, working: 1, done: 2, idle: 3 }
+
+// Pane ids in display order. activity maps pane id -> last-activity time (ms).
+// Ties keep Herdr's order.
+function order(agents, mode, activity) {
+ var rows = agents.map(function(agent, index) { return { agent: agent, index: index } })
+ var name = function(row) { return displayName(row.agent).toLowerCase() }
+ var compare = {
+ status: function(a, b) {
+ var ra = STATUS_RANK[a.agent.agent_status], rb = STATUS_RANK[b.agent.agent_status]
+ return (ra === undefined ? 4 : ra) - (rb === undefined ? 4 : rb)
+ },
+ recent: function(a, b) {
+ return (activity[b.agent.pane_id] || 0) - (activity[a.agent.pane_id] || 0)
+ },
+ name: function(a, b) { return name(a) < name(b) ? -1 : name(a) > name(b) ? 1 : 0 },
+ workspace: function(a, b) {
+ var wa = String(a.agent.workspace_label || "").toLowerCase(), wb = String(b.agent.workspace_label || "").toLowerCase()
+ return wa < wb ? -1 : wa > wb ? 1 : 0
+ },
+ }[mode]
+ if (compare) rows.sort(function(a, b) { return compare(a, b) || a.index - b.index })
+ return rows.map(function(row) { return String(row.agent.pane_id || "") })
+}
+
+// Agents in a saved order; agents the order doesn't know yet go last, in Herdr order.
+function arrange(agents, paneOrder) {
+ var position = {}
+ paneOrder.forEach(function(pane, index) { position[pane] = index })
+ return agents.map(function(agent, index) {
+ var at = position[String(agent.pane_id || "")]
+ return { agent: agent, key: at === undefined ? paneOrder.length + index : at }
+ }).sort(function(a, b) { return a.key - b.key }).map(function(row) { return row.agent })
+}
diff --git i/bin/herdr-monitor w/bin/herdr-monitor
index 1944808..aac81b3 100755
--- i/bin/herdr-monitor
+++ w/bin/herdr-monitor
@@ -113,6 +113,7 @@ def roster() -> None:
item["workspace_label"] = labels.get(workspace_id, workspace_id or "Untitled space")
item["tab_label"] = tab_labels.get(str(row.get("tab_id", "")), str(row.get("tab_id") or ""))
item["context_tokens"], item["context_limit"] = context_usage(row)
+ item["active_at"] = last_active(row)
merged.append(item)
response({"ok": True, "agents": merged})
@@ -328,6 +329,18 @@ CONTEXT_READERS = {
}
+def last_active(row: dict) -> float:
+ """When the agent last wrote to its session store (epoch ms); 0 when unknown."""
+ session = (row.get("agent_session") or {}).get("value")
+ if row.get("agent") != "claude" or not session:
+ return 0
+ try:
+ transcript = claude_transcript(str(session))
+ return transcript.stat().st_mtime * 1000 if transcript else 0
+ except OSError:
+ return 0
+
+
def context_usage(row: dict) -> tuple[int, int]:
"""(context tokens, context window) for an agent; zeros when unknown."""
reader = CONTEXT_READERS.get(str(row.get("agent", "")))
diff --git i/tests/test-alerts.mjs w/tests/test-alerts.mjs
index 9a98741..988cf9c 100644
--- i/tests/test-alerts.mjs
+++ w/tests/test-alerts.mjs
@@ -72,7 +72,8 @@ function unreadContext() {
Alerts: alerts, demoMode: false, alertBaseline: false, agents: [], unread: {},
lastSequence: {}, workingSince: {}, opened: false, selectedPane: '',
activeAlert: null, activeAlertNeedsInput: false, connected: false, errorText: '',
- dataRevision: 0,
+ dataRevision: 0, activity: {}, pendingPane: '', confirmClosePane: '', resort() {},
+ get sortedAgents() { return context.agents; },
agentForPane(pane) {
for (const item of context.agents) if (String(item.pane_id || '') === pane) return item;
return null;
diff --git i/tests/test-roster.mjs w/tests/test-roster.mjs
index 9ea8eef..b92cdb2 100644
--- i/tests/test-roster.mjs
+++ w/tests/test-roster.mjs
@@ -22,3 +22,30 @@ test('attention first, then working, then read; stable within groups', () => {
test('reading a reply moves it down; blocked agents still need attention', () => {
assert.deepEqual(order({}), ['blocked', 'busy', 'read', 'unread', 'idle']);
});
+
+const crew = [
+ { pane_id: 'a', agent: 'codex', name: 'zeta', agent_status: 'idle', workspace_label: 'web' },
+ { pane_id: 'b', agent: 'claude', terminal_title_stripped: 'Alpha task', agent_status: 'working', workspace_label: 'api' },
+ { pane_id: 'c', agent: 'claude', terminal_title_stripped: 'me@host:~/x', tokens: { claude_name: 'Mid' }, agent_status: 'blocked', workspace_label: 'web' },
+ { pane_id: 'd', agent: 'hermes', agent_status: 'done', workspace_label: 'api' },
+];
+const by = (mode, activity = {}) => Array.from(roster.order(crew, mode, activity));
+
+test('display names prefer the Claude session name, then the Herdr name, then the kind', () => {
+ assert.deepEqual(crew.map(a => roster.displayName(a)), ['zeta', 'Alpha task', 'Mid', 'Hermes']);
+ assert.equal(roster.displayName({ agent: 'claude', terminal_title_stripped: 'me@host:~/w' }), 'Claude');
+});
+
+test('sort modes; ties keep Herdr order', () => {
+ assert.deepEqual(by('herdr'), ['a', 'b', 'c', 'd']);
+ assert.deepEqual(by('status'), ['c', 'b', 'd', 'a']);
+ assert.deepEqual(by('name'), ['b', 'd', 'c', 'a']);
+ assert.deepEqual(by('workspace'), ['b', 'd', 'a', 'c']);
+ assert.deepEqual(by('recent', { d: 30, a: 20, c: 10 }), ['d', 'a', 'c', 'b']);
+});
+
+test('arrange keeps a saved order and appends new agents', () => {
+ const ids = rows => Array.from(rows, a => a.pane_id);
+ assert.deepEqual(ids(roster.arrange(crew, ['d', 'b'])), ['d', 'b', 'a', 'c']);
+ assert.deepEqual(ids(roster.arrange(crew.slice(0, 2), ['d', 'b', 'a'])), ['b', 'a']);
+});
diff --git i/tests/test_native.py w/tests/test_native.py
index baa9589..bd36bab 100644
--- i/tests/test_native.py
+++ w/tests/test_native.py
@@ -260,9 +260,13 @@ class NativeTests(unittest.TestCase):
self.assertNotIn("QMLTermWidget", source)
def test_roster_keeps_herdr_order(self):
- # Re-sorting by attention moved agents under the cursor on selection.
+ # Re-sorting live by attention moved agents under the cursor on
+ # selection. Herdr's order is the default, and other sorts use a saved
+ # order that only changes on open, a sort change, or agents coming or going.
source = (NATIVE / "HerdrHud.qml").read_text()
- self.assertIn("readonly property var sortedAgents: agents\n", source)
+ self.assertIn('property string sortMode: "herdr"\n', source)
+ self.assertIn('sortedAgents: sortMode === "herdr" ? agents : Roster.arrange(agents, rosterOrder)\n', source)
+ self.assertIn("if (!opened || paneSetChanged) resort()", source)
self.assertNotIn("Roster.sorted(", source)
def test_release_structure(self):
Binary file not shown.
@@ -0,0 +1,48 @@
diff --git i/Cat.qml w/Cat.qml
index 58ed7f6..f1bbe9b 100644
--- i/Cat.qml
+++ w/Cat.qml
@@ -30,13 +30,18 @@ Item {
property string omarchyPath: ""
readonly property string pluginId: manifest && manifest.id ? String(manifest.id) : "io.github.tallsam.navbar-cat"
+ // The shell strips `__sourceDir` from third-party manifests, so fall back to
+ // this file's own directory.
+ readonly property string pluginDir: manifest && manifest.__sourceDir
+ ? String(manifest.__sourceDir)
+ : String(Qt.resolvedUrl(".")).replace(/^file:\/\//, "").replace(/\/$/, "")
readonly property string assetDir: {
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
+ var dir = pluginDir
if (dir === "") return ""
return (dir.indexOf("file://") === 0 ? dir : "file://" + dir) + "/assets"
}
readonly property string helper: {
- var dir = manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : ""
+ var dir = pluginDir
if (dir === "") return ""
return dir.replace(/^file:\/\//, "") + "/bin/navbar-cat-cursor"
}
@@ -52,8 +57,21 @@ Item {
// Settings live inline on this plugin's entry in shell.json's `plugins[]`,
// per the shell's storage rules.
+ // Third-party plugins get a scoped `shell` without `shellConfig`, so read
+ // shell.json directly when the shell does not hand it over.
+ FileView {
+ id: shellJson
+ path: Quickshell.env("HOME") + "/.config/omarchy/shell.json"
+ watchChanges: true
+ onFileChanged: reload()
+ }
+ readonly property var fileConfig: {
+ try { return JSON.parse(shellJson.text()) } catch (e) { return null }
+ }
+
readonly property var config: {
- var settings = shell && shell.shellConfig ? shell.shellConfig.plugins : null
+ var source = shell && shell.shellConfig ? shell.shellConfig : root.fileConfig
+ var settings = source ? source.plugins : null
if (Array.isArray(settings)) {
for (var i = 0; i < settings.length; i++) {
if (settings[i] && String(settings[i].id || "") === root.pluginId) return settings[i]