protocol Exec { /// Run argv on the machine. Returns stdout as a stream of lines, a stdin writer and the exit. func run(_ argv: [String]) async throws -> Channel func herdr() async throws -> String? // from the login shell func forgetHerdr() async // after Install herdr func read(home path: String) async -> String? var isRemote: Bool { get }}
The app reaches each machine through run (D2). Nothing of ours is
installed on the machine: sshd, POSIX tools and herdr do the work. The chat
view writes small helper files under ~/.cache/ovvi before an agent launch
(Side files). Plugin cards read, save, list
and watch files and run git the same way
(File Explorer and Git,
Commands).
This Mac:Process. A GUI app does not get the shell's environment. So
at launch the app reads PATH, HOME, SSH_AUTH_SOCK, every XDG_*
variable and herdr's path once with $SHELL -lic (stdin closed,
3 s timeout, output parsed after a unique marker). HERDR_* variables are removed from
child processes. A program starts by its full path, which the app looks up
on that PATH at each run (no env process), so a program installed or
moved later is found. The app reads and writes card state files itself,
with no process: a write goes to a temporary file in the same folder, then
a rename, with new folders 0700 and the file 0600. On a remote machine the
same write runs as one sh over the relay.
A remote machine: every run goes through the relay, one SSH
session on the host's ControlMaster (The SSH connection). The
app starts it with a Process like this, the relay's script in the
envelope:
The remote command envelope. ssh hands one string to the
machine's login shell, which may be bash, zsh or fish. Their quoting rules
differ. So the app never builds a shell string from data. It sends:
sh -c 'eval "$(printf %s "$1" | base64 -d)"' sh <BASE64>
<BASE64> holds the real command as POSIX sh, with every argv element
single-quoted. Base64 contains only A–Z a–z 0–9 + / =, so every shell
passes it through unchanged.
Ending a child = closing its stdin. A control run exits in about
2 ms and herdr frees the pane at once (keys written before the close still
arrive). If the app quits or crashes,
the kernel closes its pipes, so every child ends by itself, remote ones
too: ssh closes the channel. (Measured with herdr 0.9.5, on This Mac and
over SSH.)
observe and tail -F (the chat's followers) ignore a closed stdin, so
they always run in this wrapper, on This Mac too:
sh -c 'exec 3<&0; "$@" & c=$!; { cat <&3 >/dev/null; while kill $c; do sleep 1; done; } >/dev/null 2>&1 & w=$!; wait $c 2>/dev/null; s=$?; kill $w 2>/dev/null; exit $s' sh <cmd>
The wrapper sends its kill again each second until <cmd> is gone:
macOS's sh can lose one, and a tail -F that misses it runs forever.
One fallback: a terminal stream that the app ends by closing its stdin is
killed by its pid (SIGKILL) if it still runs about 1 s later (a hung herdr,
a write stuck on a full pipe). A one-shot command whose
stdin is closed only so it reads end of input is not killed. A dropped
stream or a timeout kills at once, except a follower's: a kill before the
wrapper started its watcher would leave <cmd> running, so a dropped
follower only closes its stdin. The app signals a child only by its pid,
never by process group.
herdr installs to ~/.local/bin, which only the user's shell config puts
on PATH. So the app asks the machine's login shell once,
$SHELL -lic 'command -v herdr || command -v "$HOME/.local/bin/herdr"', and
takes the last line if it is an absolute path (an rc file may print first).
The second lookup finds a herdr that its installer put in ~/.local/bin
when the shell's PATH lacks that folder (macOS's zsh has not got it, nor
has many a remote machine's). Every herdr call on that machine
runs that path; PATH stays the login shell's own. On This Mac the same
shell run reads the environment, at launch
(Running commands). On a remote machine it is one run over SSH
on connect, and the path goes inside the envelope.
This Mac's shell gets 3 s. One that takes longer (a busy Mac, slow startup
files) is no answer: the next need for herdr asks again at once, with 25 s,
and every caller waits for that run.
No path (herdr missing, or This Mac's shell failed or took over 25 s): the
app shows the install command with Install herdr
(Install herdr) and asks the shell again, at most every
30 s while missing and one run at a time, so it finds a herdr that the user
installs later. On This Mac, a path whose file is gone (herdr moved or
reinstalled elsewhere) is asked for again the same way. A snapshot that
reports a version below 0.9.3 shows "herdr is too old" over "herdr ‹version›
is too old; 0.9.3 or newer needed." The app asks again every 2 s, so the
page clears by itself after an update.
The version comes from session.snapshot. Decoding is lenient, so a newer
protocol needs nothing.
Paths in the home folder go to the machine relative to it, and the command
adds $HOME, so it works in any working folder.
Every herdr argv carries --session <name> (also --session default), so
no inherited variable can point a command at another session.
Install herdr runs the command from herdr's docs,
curl -fsSL https://herdr.dev/install.sh | sh, as
sh -c '(set -o pipefail) 2>/dev/null && set -o pipefail; exec 2>&1; <command>'
(every command's errors in one stream, and a failed curl fails the pipe
where the shell has pipefail): on This Mac in the app's environment, on a
remote machine over its SSH connection (Running commands). It
installs to ~/.local/bin.
While it runs, the page shows the loader "Installing herdr…" with the
installer's last line under it (its colors taken out). It has no Cancel: a
kill reaches only the sh, never the pipe's curl and sh (the app never
signals a process group).
When it exits 0, the app forgets the machine's herdr path and asks the login
shell again. On This Mac it then starts the page's session (D47). Every page
of the machine reads again; the loader's line says "Connecting…" until they
leave "not installed" (at most 10 s).
When it fails, or the login shell still finds no herdr, the
page shows "herdr did not install" over the installer's last 6 lines and
the reason, with Try Again.
A herdr that is too old gets no button: the running server's version counts,
and only a restart of the session, which ends its programs, changes it.
-herdrInstall <command> replaces the command; the e2e tests use it, so no
test runs herdr's installer.
call(method, params): connect to the session's API socket (Exec.api),
write one line {"id":"1","method":"…","params":{…}}, read one reply
line, then close the connection. herdr answers one request per connection
and then closes it (herdr 0.9.5, handle_connection). The app connects in
its own process, with no herdr process, on every machine (D4):
This Mac: herdr's own socket. Its path is herdr's rule (its config
folder, from XDG_CONFIG_HOME or ~/.config/herdr, and sessions/<S>/
for a session other than default), so the app asks herdr:
session list --json gives each session's socket_path. It asks once
per session and keeps the path. A connect that fails (the server
stopped, restarts, or the session was deleted) asks again and tries once
more, as a remote machine forwards again; then the request fails.
A remote machine: a local socket that the host's ControlMaster
forwards to herdr's socket there:
ssh -F /dev/null -S <ctl> -O forward -L <ctl>-<s8>:<socket_path> <target>,
where <s8> is 8 hex characters of a hash of the session's name. The
remote socket_path comes from the same session list --json, run over
the relay, once per session and connection. Each request opens one
channel on the SSH connection, not a session (MaxSessions holds). ssh
binds the local socket 0600 (StreamLocalBindMask=0177 on the master) in
the app's 0700 folder, and the name has the app's pid, so a second Ovvi
on the Mac has its own. ssh leaves the file behind when a master ends, and
refuses to bind over it: a new forward removes it first. A new master
(after a drop) forwards again on the first request; a connect that finds
no listener forwards again and tries once more, then fails. On quit the app removes its
forwards' files with its masters.
A remote end that fails: ssh takes the connection and closes it with
no answer when the machine's end fails. The app then reads
session list --json over the relay: a session that is not running is
the usual "not running"; a running one whose socket_path changed is
forwarded again; a running one at the same path means the host's sshd
refuses to forward a socket (AllowStreamLocalForwarding no, or
DisableForwarding), and the page shows "‹target› refuses to forward
herdr's socket: Ovvi needs sshd's AllowStreamLocalForwarding" under
Reconnecting. There is no fallback.
Timeout: every call() times out after 10 s. On a timeout the app
closes the connection. A write that times
out shows the notice "herdr did not answer", with no Retry. A read that
times out shows Reconnecting. A connection is closed exactly once: at its
end, when its reader stops (a cancelled call) or on a timeout.
Long-lived streams (events, terminals) have no timeout. The
health check covers them.
Cost: 0.4 ms of the app's CPU and no process per request on This Mac,
plus the network round trip on a remote machine (measured: a bridge
process per request took 11 ms of CPU and 63 ms median, 1.6 s at p90, on a
loaded Mac). Text typing never takes this path (special keys do:
Terminal plane).
One long-lived connection to the API socket per open session carries
events.subscribe; on a remote machine it is one more forwarded channel. A
session without a page in any window has none
(Open sessions).
Subscriptions (herdr accepts no wildcards):
workspace.created|updated|renamed|moved|reordered|closed. Not
workspace.metadata_updated: the app reads no space tokens, and herdr
sends it on every token write, also one that changes nothing.
tab.created|closed|renamed|moved
pane.created|closed|moved|updated|exited|agent_detected. herdr sends
pane.updated when terminal_title_stripped changes, which can pick a
pane's icon (Program icons). Spinner frames
send none (herdr strips them); a title with changing text, such as a
progress count, sends one per change, and the snapshot pacing below
absorbs them.
layout.updated
pane.agent_status_changed, one entry per pane listed in
snapshot.agents[]. Each entry makes herdr poll that pane every 100 ms,
so plain shells are left out.
No *.focused events. They fire on every herdr TUI navigation, and the
app ignores herdr's focus.
herdr sends no event for pane.rename (0.9.5). So a process that renames
a browser pane by hand follows it with a pane.report_metadata token,
whose pane.updated makes the app read the snapshot
(Agents open browser panes).
ovvi open needs none: the app makes the change and reads the snapshot
itself.
Any event line means "read session.snapshot again". Event payloads
are never decoded, except an agent request.
Snapshot pacing, per session: at most one session.snapshot is in
flight. An event, or the app's own write, starts a read at once if none is
in flight; otherwise it sets "read again", and the read that ends with it
set starts one more, 250 ms after the one before started at the earliest
unless the app's own write asked for it (its result is read at once).
A read takes 20–130 ms, so it batches the events that arrive during it; the
250 ms keep a hundred agents' titles and states (an event each) from
becoming reads back to back, each a snapshot of every pane (about
280 KB at 400 panes).
The stream is opened again, followed by a snapshot, when the set of agent
panes changes, on events_lost (the reader fell behind herdr's 512-event
buffer) and on pane_not_found (a listed pane closed first).
The health check reads the snapshot every
30 s, so a missed event is caught there.
An agent in a pane asks Ovvi for something with the ovvi command, on any
machine Ovvi follows (D104,
Agent control). The command writes its arguments to a
file in ~/.cache/ovvi/rpc/ on its machine and rings: the pane token
ovvi-req = the request id, source ovvi-cli, ttl_ms 3000. On the
pane.updated event or the snapshot that carries a new id (the snapshot
covers a ring while the event stream opens again), Ovvi takes the file with one
run, does the work, and writes the answer back as files with one more run
(Protocol).
Ovvi writes two presence tokens, so a process in the session knows what
Ovvi does with no call to Ovvi (ovvi status):
Token
What
ovvi-shown on a pane
pane.report_metadata on each pane in view of a visible window (the shown tab of the page in view, only its zoomed card when one is, and the floated panes): source ovvi, 1 on This Mac, remote on another machine (for pi's copy), ttl_ms 30000. Written when a pane comes into view, again every 10 s while it stays, and cleared (null) when it leaves view, its page closes, its window hides or closes, or the app quits (which waits up to 1 s for it). Over the relay for a remote machine
ovvi-chat on a pane
pane.report_metadata once a launch that dropped its arguments is bound to its processes (Which panes have it): source ovvi-chat, ‹command› ‹marker› ‹pid›,…, no ttl_ms, so it stays with the pane while herdr's server runs (a restart drops it with the terminal). Read at the first sight of a terminal (the app starting, a reconnect)
ovvi-open on a space
workspace.report_metadata on each space of a session that a window has open (a float-only page too): source ovvi, 1 (the protocol version), ttl_ms 30000. Written on a snapshot with a new space and every 10 s, in the same burst as ovvi-shown and after it (at quit, all at once); cleared when the last window lets the session go and on quit
herdr sends pane.updated with the whole pane, tokens included, on each
report and again when a token expires. pane.get and session.snapshot
list a pane's tokens, and workspace.get a space's.
The tokens are one map per pane, by name, whatever the source: a report
changes only the tokens it names. Ovvi's never touch pi's own metadata
(agent, title, state labels, its tokens). Two Macs showing one pane share
ovvi-shown: one's clear drops the other's mark until its next refresh.
Each token's ttl_ms is its own. An app that crashed stops claiming its
panes within 30 s. A pane herdr moves keeps its mark until then too (the
clear goes to its old pane id).
herdr 0.9.5: names match ^[A-Za-z0-9_-]{1,32}$ (no dots), 16 tokens per
report and 32 per pane at most, values cut at 80 characters.
Ovvi decodes ovvi-req only when it is a lowercase UUID version 4, and
acts once per id: it keeps the last 256 ids per session.
When the app first connects to a session on a machine, it writes the
command to ~/.cache/ovvi/bin/ovvi there when the file is missing or
older, and removes mailbox files older than 60 minutes
(How the command gets there).
The chat view talks to herdr only through the calls
below. Everything else goes to the agent's own files, on its machine.
The marker.pane.process_info returns foreground_processes[] with
each process's argv. A chat pane is one where an adapter finds its launch
marker there (Launch flags), asked at
the times on Program icons.
The app writes only the ovvi-chat token (above) for a chat.
The session. Each pane's agent_session in the snapshot names its
agent's session, for Session titles
and Relaunch with Chat. Until
herdr reports one, the UUID after resume in the foreground argv is the
session for Relaunch with Chat (codex resume <id>).
Send is agent.prompt {target, text}. herdr refuses it ("no longer the
pane foreground process") unless a foreground process has the agent's
name, and while herdr reports the agent blocked (pi-ovvi reports pi's
dialogs to herdr's pi integration).
Stop is pane.send_keys, and text that starts with / is
pane.send_input. Codex's screen check is pane.read {source:"recent"}.
The phase is herdr's agent status when the adapter has none of its
own (Ready, working, waiting).
The followers are runs like any other: one per followed file, in the
wrapper above, over the relay on a remote machine. The relay takes one line
per write, so pi-ovvi goes to the machine as one base64 line.
One snapshot holds every space, tab, pane, agent and layout.
The session's HostStore applies it to stable @Observable objects (Workspace,
Tab, Pane) keyed by id, and sets only the fields that changed. A status
change redraws one row, not the window.
Ids are scoped by session (two sessions can both have w1:p1). Every
card is keyed by (machine, session, terminal_id), which survives every
move. A
herdr cold restart gives new terminal ids, so cards are built again.
Hand-written, lenient Codable models cover only the fields the app uses.
herdr terminal session control <terminal_id> --cols C --rows R [--takeover] stdout → {"type":"terminal.frame","seq":1,"width":C,"height":R, "full":true,"bytes":"<base64 ANSI>"} {"type":"terminal.closed","reason":"…"} stdin ← {"type":"terminal.input","text":"ls\r"} {"type":"terminal.resize","cols":120,"rows":40, "cell_width_px":8,"cell_height_px":17} {"type":"terminal.scroll",…} {"type":"terminal.mouse",…}
herdr draws the pane at our size and sends ANSI updates. A libghostty surface
draws them. Input takes two paths:
Text (typing, IME, paste, plain Enter, Tab and Backspace) goes on the
stream as terminal.input.
Special keys (arrows, Esc, function keys, keys with modifiers, Ctrl
chords, and the macOS line-editing keys such as ⌘← and ⌥←) go as
pane.send_keys, one request per burst. Their bytes depend on the
program's input modes, and herdr encodes them for those modes. A Ctrl
chord keeps its Shift: under the kitty keyboard protocol ⌃⇧S is its own
key, and a program without that protocol gets ⌃S's byte (for a letter). Keys that
pile up during a call go in the next one, in order with the text.
One path for all input waits on herdr
(Future work).
The panes of the shown tab of the page in view, in each window. Other
pages stream nothing, except in the overview (below).
On the Mac, the 25 most recently shown other terminals (spareTerminals), over all pages of
all windows, keep their streams and their libghostty surfaces (each window
has its own PaneViewRegistry; they share one count), least recently shown
first out (D46). So coming back to a tab visited before is at once: no new
surface, no fade, no wait for herdr's frames, and no resize. A pane shown in
two windows has two surfaces and two streams, and counts twice. While the
overview is open, the tiles show pictures: the terminals in view take a
snapshot as it opens, and only the cards of the page in view's tiles in
view that have no snapshot yet stream and draw until they drew, at most 16
cards (6 on a remote machine); its miniatures are no visit: a terminal it
alone showed goes back to how it was when the overview closes.
On critical memory pressure from macOS, the kept terminals out of view go
down to half (12), with the kept web cards
(What stays in memory).
Only the shown tab's terminals (and floating panes) draw. The others still
read their stream into their surface, so they show what came meanwhile the
moment they are shown. As Ghostty does for hidden tabs and covered windows,
a surface that does not draw is occluded: libghostty frees its swap chain
(its three IOSurfaces at the card's size, about 17 MB each for a full-window
card on a Retina screen, and its Metal buffers) and makes it again in under
a millisecond on the next draw. libghostty empties those IOSurfaces as it
frees them, so a card copies its last frame (one IOSurface, about 2 ms)
just before it stops drawing; the copy shows over the surface when the card
is back in a window, until the surface's first new frame that has text or a
cursor (about 0.1 s). A swap chain made again at a new size can draw an
empty frame first; three empty frames in a row mean the screen is empty.
libghostty also stops drawing in a minimized, hidden or covered window.
The app decides which cards draw (inView), not libghostty: libghostty
marks a view visible again whenever it moves to or from a window
(viewDidMoveToWindow calls setWindowVisible(true)), so left to it,
hidden cards would draw every output.
A terminal past those 25 lets go of its stream (its herdr run ends) and of
its surface. Its copy stays as a still, purgeable while no window shows it,
so it costs no memory then (the system takes such pictures back within
seconds). When it is shown again, a new surface is made under the
still and the stream opens again; herdr's first frame redraws the screen,
and the still goes once the new surface has drawn herdr's settled frames,
so the card is never empty. A still the system took back gives the card's
fill and the first fade-in.
A terminal gets its surface only when it first streams, so a card that
never shows costs no GPU memory.
One terminal session run per streaming terminal, also in one session:
herdr 0.9.5 ties each client connection to one terminal
(ControlTerminal {target} on a connection still in the pending terminal
mode), and the CLI takes one target. One run costs about 7 MB and almost
no CPU. Many terminals per run would need herdr's private client protocol
on its socket.
Streams are opened by terminal_id, so they survive the pane moving, even
to another space.
Card size ÷ cell size gives cols × rows. While the card's size keeps
changing (a window drag, a divider drag, the sidebar), its
terminal keeps its grid, clipped at the top left. Once the size has not
changed for 150 ms, or a window drag ends, the terminal takes the card's size
and the new grid goes to herdr at once. A card's layout motion gives its
content its new size once, as the motion starts: the terminal takes it then.
Pixel-only changes send nothing.
herdr answers every resize, even to the same size, with a full frame
("full":true); its other frames only carry what changed.
At a new grid (a resize, a new text size) a card that is not watching
pauses libghostty's drawing and puts a copy of its last picture over the
surface, so neither libghostty's reflow of the old screen nor herdr's first
frame shows: the card keeps its last picture, at the top left, until
herdr's frames settle (below), then shows the new one at once. The copy
goes with the first new frame that has text or a cursor, so the card never
goes blank.
A control frame's width and height are the real PTY size.
herdr has no way to size a PTY without attaching (the attach's --cols /
--rows resizes it), so a pane opened for the first time redraws once at
the card's size: herdr's first frame is the old screen reflowed at the new
size, and the program's redraw for SIGWINCH follows. The card draws
nothing until herdr's frames stop for 0.1 s (at most 0.4 s after the first
frame or the resize), then fades its terminal in (0.06 s). A fade that does not run
leaves the terminal shown, never hidden. Background tabs are not attached ahead of time
(D46's cap).
control can type and resize. observe only watches.
Open as control without --takeover. If herdr refuses (another
device holds the pane), open as observe and show "In use elsewhere ·
Take Back".
On any device, the first key press, paste, or click or tap inside a
watching card, or Take Back, opens the stream again with
--takeover. Keys typed before the new stream's first frame are queued
and sent in order.
Nothing else takes control. A watcher stays a watcher until its user acts,
even after the other device lets go.
A device closes a stream (closes its stdin) when it stops showing the
card (What streams). herdr frees the pane at once.
Two windows on the Mac can show the same session. Control is per session
(D66):
The window where the user last typed, pasted or clicked in a terminal card
of the session holds that session's control streams.
Another window that shows the session opens observe streams for its
cards, at its own card size. Its cards dim a little, and it shows
"Controlled in another window · Take Back"
(Control capsule). Its cards show no
per-card "In use elsewhere" label: the capsule covers them.
Take Back, or a key press, paste or click in a terminal card of the
watching window, moves control: that window opens its streams again as
control with --takeover. herdr's takeover ends the other window's
streams, and they open again as observe (D8); herdr closes them with
the reason "terminal attach taken over". The window's other streams of
the session (recent tabs) take over too, and the other windows' watch. Keys
typed before the first frame are queued, as for a device.
The session's HostStore records which window controls it, so its
windows never take over from each other by themselves.
When the controlling window closes, or closes the session's page, the
next window that shows the session controls it and takes over its streams,
so the PTY takes its card size.
Windows on different sessions each control their own session.
Another device can still hold a single pane. That card shows "In use
elsewhere · Take Back", as in One controller per pane.
After a terminal.closed line the CLI exits 0. If it cannot connect, or the
handshake fails, it exits 1 with the reason on stderr.
Reason contains
The app
already has an attached client
watches; shows Take Back
taken over
watches; never takes back by itself
not found or exited
drops the card; waits for the snapshot
detached
does nothing (this device closed the stream)
live update
opens the stream again after 1 s
any other failure (exit 1, for example a handshake error after herdr updated but did not restart)
shows herdr's reason and Retry in the card; opens again by itself at most every 5 s while the page is live, and at once when a page that reconnected is live again
Scrolling:terminal.scroll goes to the program when it uses the mouse
or alternate scroll (most agent TUIs). Otherwise it scrolls herdr's shared
viewport. The app keeps no scrollback. terminal.scroll goes only up and
down, so a sideways swipe over a terminal card does nothing.
Terminal queries: herdr already answers them for the pane.
Copy from a program does not reach the Mac's clipboard when the
program copies with OSC 52, the escape sequence that sets the clipboard
(vim's "+y, an agent's copy on select). herdr gives that write only to
its own terminal app (TUI) in front, and terminal session control
prints no event for it. Most programs on this Mac copy with pbcopy, so
this mostly hits programs on another machine, also through ssh in a
local card. Select the text in the card and press ⌘C instead.
pi-hkandala's extensions copy through ovvi copy
(CLI). herdr
sends the app no clipboard read either; if one came, the app would refuse
it.
A herdr TUI on the same tab shares the split ratios, and the app's
control stream pins the PTY size. Accepted.
Find (⌘F) reads the pane's recent text with
pane.read {source:"recent", lines: 5000, format:"text"} when the bar
opens (again at the next step after Return in the pane), and searches it on
the device on each edit. Each match scrolls herdr's shared viewport with
pane.scroll {pane_id, offset_from_bottom}: the match line's distance from
herdr's last row, less half of viewport_rows. herdr's text leaves out the
screen's blank rows under its last text, but the offset counts them, so the
bar adds them: from pane.read visible at the bottom, else from
pane.get's max_offset_from_bottom + viewport_rows less the text (a text
cut at 5000 lines counts none). A herdr TUI on the same pane scrolls too.
After each scroll, and 0.3 s after a burst of frames (at most 1 s of
waiting), pane.read visible and pane.get (scroll) place the
highlights. herdr's own
pane.copy_search is not used: it needs a content_revision and a cursor
from herdr's TUI client protocol and otherwise answers stale_content.
Links in terminal output open in the app with ⌘-click in a new tab and
⌥-click to the side, as every link
opens (Opening links and files):
a web address in a browser pane, a file path that is there with its
default plugin, HTML on This Mac in a browser pane. ⌘⌥-click opens it in
the Mac's default app instead, with no herdr call. ⌥ and ⌘⌥ show a link
as ⌘ does.
The sidebar's space rows show the branch and the ahead and behind counts
(D67). herdr's snapshot has no branch, so the app asks git on the machine:
Command.git --no-optional-locks -C <root> status --porcelain=v2 --branch --untracked-files=no (--no-optional-locks, so a run never
leaves index.lock behind), through Exec.run, so it works the same
on This Mac and over SSH. git comes from the login shell's PATH, like herdr. The
root is the space's worktree.checkout_path, else the cwd of its first
pane. A run that takes over 5 s is ended.
Parse. The # branch.head line gives the branch; when it is
(detached), the first 7 characters of # branch.oid.
# branch.ab +A -B gives ahead and behind (absent without an upstream). The
app reads no other line. A nonzero exit means the folder is not a git
repository, and the row has no git line.
When. For a space that is new or whose root changed, when git's own
files of its root change, and every 60 s for every space, only for the page
in view of a visible window. Hidden pages never run and watch nothing. One
run per space at a time; a change that comes during a run reads again after
it. Fetch, Pull and Push in a row's menu read it again when git ends.
Git's own files. One git rev-parse --absolute-git-dir --git-common-dir per root gives them: the work tree's HEAD (a linked
worktree has its own), and its repository's refs/, packed-refs,
reftable/ and config (the upstream). The index and objects/ are left
out, so git add or a build reads nothing. The watch is the same on every
machine, with no process per check: one long-lived Perl run on the machine
(over the relay on a remote one) that compares the files' lstat every 2 s
and ends when its stdin closes. A watch that ends (a
dropped connection) or finds no repository starts again 60 s later, and
then reads once, for what it missed. Every space is read again every 60 s,
which also covers what no watch sees (a folder that became a repository).
No developer tools. At launch the app checks once where git is. When
it is only /usr/bin/git and xcode-select -p fails, the app runs no git
on This Mac, so macOS never offers to install the developer tools. Rows on
every machine then have one line. This Mac's File Explorer cards show no
git letters and its Git cards say "git is not installed". Plugin cards on a remote machine
still run that machine's git.
Worktrees. herdr's worktree.is_linked_worktree and repo_key place a
linked worktree under its repository's space. No git run is needed for
that. herdr 0.9.5 fills a space's worktree only after a worktree.*
call looked at it: worktree.list, worktree.open or worktree.create.
A plain workspace.create {cwd} in a repository leaves it out, so its row
is never a worktree row. worktree.open also fills the open space of
the worktree's repository.
The result lives in memory on the session's HostStore.
New space (page header +, space menu, an empty session's New Space)
workspace.create {cwd: "~", label, focus: false} in that page's session (herdr reads ~ as its machine's home; a launchd-started server's cwd is not home); the label only when the user typed one; then the snapshot selects it
Folder dropped on the Dock icon
workspace.create {cwd, focus: false}, one per folder, in the session of the last key window's page in view, once that page is live; the last one is selected when it appears. With no window (a drop that starts the app), a window opens first, on the new-window view: the folders wait for a This Mac session the user picks
New tab
tab.create {workspace_id, cwd}, with the cwd of the keyboard card; from a space row's New Tab, that space's keyboard card; while a new tab's stand-in shows (⌘T again), the keyboard card of the tab used last there; else ~
Split right or down
pane.split {target_pane_id, direction: right or down, focus:false}, then equal splits when Balance splits automatically is on: a fresh snapshot, and one layout.set_split_ratio per split of the new pane's run whose ratio changes
Split left or up
pane.split right or down (focus:false), then pane.swap {pane_id: old, direction: right or down}: the old pane swaps with the new one beside it (herdr splits only right and down); then equal splits
Drag a divider
layout.set_split_ratio {tab_id, path, ratio} on release; ratio kept in 0.1–0.9
Double-click a divider
layout.set_split_ratio with ratio 0.5
Balance splits (⌥⌘=)
one layout.set_split_ratio per split of the selected tab whose ratio changes; ratio = units on the first side ÷ units under the split, where a child split the other way is one unit
Swap two panes (same tab)
pane.swap {source_pane_id, target_pane_id}
Move the keyboard card (⌥⇧⌘ + arrow)
pane.swap {pane_id, direction, focus:false} (herdr 0.9.5 focuses that tab anyway); the keyboard stays with the pane
Move Pane to Tab ▸ ‹tab› (card menu)
pane.move {pane_id, destination:{type:"tab", tab_id, split:"right", target_pane_id}} beside the tab's last card, then equal splits (as a drop on its capsule); the window then selects that tab
Move Pane to Tab ▸ New Tab (card menu)
pane.move {pane_id, destination:{type:"new_tab", workspace_id}}; the window then selects the new tab
Move Pane to Tab ▸ New Space (card menu)
pane.move {pane_id, destination:{type:"new_workspace"}}; the window then selects the new tab. The pane gets a new pane id; its terminal and card stay
Drop on an edge of a pane in the same tab
pane.move {pane_id, destination:{type:"new_tab", workspace_id}}, then pane.move {pane_id, destination:{type:"tab", tab_id, split, target_pane_id}} back beside the target (right or down), then pane.swap {source_pane_id, target_pane_id} for left or top, then equal splits; each step uses the pane id herdr's reply names, and the snapshot is read after (herdr refuses a move into the pane's own tab with same_tab)
Drop on an edge of a pane in another tab
pane.move {pane_id, destination:{type:"tab", tab_id, split, target_pane_id}}; for left or top, then pane.swap; then equal splits
Drop on another tab's capsule
pane.move {pane_id, destination:{type:"tab", tab_id, split:"right", target_pane_id}} beside the tab's last card, then equal splits
Drop a tab on another space's row or menu row, or on New Space
its first pane pane.move {pane_id, destination:{type:"new_tab", workspace_id, label}}, or for New Space {type:"new_workspace", tab_label} (the label unless herdr's number), then each other pane, top down, pane.move {pane_id, destination:{type:"tab", tab_id, split, target_pane_id, ratio}} beside the first pane of its split's other side at that split's ratio, each with the pane id herdr's reply named; then the snapshot. herdr's tab.move stays inside a space, and layout.apply makes new terminals
Drop on the empty area at the bottom of the sidebar page, or on its header's +
tab.move {tab_id, insert_index}, inside its space only. herdr inserts before the tab now at insert_index (0 to the count), so one place left is index − 1 and one place right is index + 2. The bar shows the new order before herdr answers, until a snapshot has it
Move Up or Down, drag a space
workspace.move_block {workspace_ids, before_workspace_id} with the row's group: the space and its linked worktrees, in order; without before_workspace_id the group goes last. A dragged worktree alone goes before another worktree of its repository, or (last) before the space after the last one. The sidebar shows the new order before herdr answers, until a snapshot has it
Open Session: a new name, a running or a stopped session
starts herdr --session S server when it is not running (a launchd job on This Mac, D47; nohup on a remote machine) and waits for session.snapshot; a server the app started with no space gets workspace.create {cwd: "~"}; then the page opens and its HostStore connects (Checks on connect)
Open Session: Stop, Delete
herdr --session S session stop S (the server ends, and every program in it) or session delete S (a stopped session only); both ask first; then the list is read again. A failure shows herdr's reason with Retry
Detach Session
no herdr call; the page's store disconnects when no window shows it, and the session keeps running
Close a pane
pane.close; asks first unless the pane's only foreground process is its shell (pane.process_info). With Balance splits automatically on and the pane in a split, a snapshot first, then the close, then one layout.set_split_ratio per split of the pane's run whose ratio changes (equal splits)
Another client opens a pane, or a pane closes by itself or from another client
with Balance splits automatically on, once a snapshot shows it: a fresh snapshot, and if the tab is as that snapshot showed it, one layout.set_split_ratio per split of the pane's run whose ratio changes
Replace Pane (⇧⌘R)
pane.split {target_pane_id, direction: right, focus:false}, then pane.close of the old pane: herdr 0.9.5 has no respawn, so the new pane, which herdr starts in the old one's live folder, fills the old one's place and size in the tree. The new pane stays hidden until a snapshot no longer has the old one, so it never shows beside it. Asks first as Close Pane does
Open Terminal Here, Start Agent Here
Replace Pane's calls; Start Agent Here then pane.send_input of the agent's launch line and Return (the bare command once more if the shell comes back at once), and pane.get every 250 ms until herdr reports the agent (New pane)
Send in a chat
agent.prompt {target: pane_id, text}, only while the agent is ready or working (Sending and stopping)
Stop in a chat
pane.send_keys {pane_id, keys: ["escape"]} (OpenCode: twice), only while the agent works
Chat text that starts with /
pane.send_input of the text, with no Return; the card shows its terminal
Relaunch with Chat
while the agent works or waits, pane.send_keys of its Stop keys; pane.send_keys {keys: ["ctrl+c"]}; pane.send_input of its quit command and Return; pane.process_info and pane.get until only the shell runs and herdr reports no agent (30 s at most); then pane.send_input of the launch line with the resume arguments, and the loader's calls (Relaunch with Chat)
tab.create {workspace_id, cwd, focus:false}, then tab.move to right after the space's selected tab; with no space for the folder workspace.create {cwd, focus:false}; to the side pane.split {target_pane_id, direction: right, cwd, focus:false} of the keyboard card. Then the chooser's start: pane.send_input of the agent's launch line with its session, and Return
Browser in the chooser, Open Browser Here
layout.apply {workspace_id, focus:false, root:{type:"pane", cwd, label:"ovvi:browser#<iid>", command}} (a new tab with the labeled placeholder pane, never a tab_id), then pane.move beside the old pane and pane.close of the old one; a failed move closes the new pane (Create)
A link opened in a new tab (⌘-click, target=_blank, Open Link in New Tab)
layout.apply {workspace_id, focus:false, root:{type:"pane", cwd, label:"ovvi:browser#<iid> <url>", command}} and pane.get of the new pane, then tab.move {tab_id, insert_index} to right after the card's tab (herdr adds the tab last), with the link in the label (Popups, links and dialogs)
A link opened to the side (⌥-click, Open Link to the Side)
the same layout.apply, then pane.get of the new pane and pane.move of it beside the card; a failed move closes the new pane
File Explorer or Git in the chooser, Open File Explorer Here, Open Git Here
git rev-parse --show-toplevel on the machine for the root (none when git cannot run), then the browser's calls with the label ovvi:explorer#<iid> <root>/ or ovvi:git#<iid> <root>
An open from a plugin card in a new tab (⌘-click, Git's Open File)
layout.apply {workspace_id, focus:false, root:{type:"pane", cwd, label, command}} (a new tab, in its card's folder), pane.get of the new pane, then tab.move {tab_id, insert_index} to right after the card's tab (herdr adds the tab last; the bar shows it there from its first frame); the snapshot after it selects the tab, unless the open is in the background (a ⌘-click) (Opening)
An open from a plugin card to the side (⌥-click)
the same layout.apply and pane.get, then pane.move of the new pane beside the card; a failed move closes the new pane
A move inside a plugin card (a click, Back, Forward)
pane.rename {pane_id, label:"ovvi:<plugin>#<iid> <place>"}, also to a label the last snapshot already has
A browser or plugin pane that runs only its shell, or no program (The placeholder; not a user action)
pane.send_keys {keys: ["ctrl+u"]}, then pane.send_input of exec ‹placeholder› and Return; a pane with no program gets Open Browser Here's calls (above) with its own label
Close a tab
tab.close; asks first only when a pane of it runs more than its shell (pane.process_info of each pane, at once), naming what runs, with the pane count
Close a space
workspace.close {workspace_id, close_group}; asks as for a tab, with the tab and pane count. close_group: true for a repository's space with linked worktree spaces: herdr refuses the close without it (workspace_group_close_required), and with it closes all of them, also ones not next to it. The git worktrees stay on disk. A worktree's own space closes alone
Close Other Tabs, Close Tabs to the Right
one tab.close per tab, all at once; asks once as for a tab, with the total pane count
Rename a tab or space
tab.rename {tab_id, label} / workspace.rename {workspace_id, label}; an empty or unchanged name sends nothing
Rename a pane
pane.rename {pane_id, label}; an empty name sends label: null, which clears the label
Go to Directory (⇧⌘G)
pane.send_input of cd -- '‹path›' and Return, only when the pane runs only its shell
Insert Path (⇧⌘I)
pane.send_input of the shell-quoted path, no Return
Find next or previous (⌘F)
pane.scroll {pane_id, offset_from_bottom} to the match; offset 0 when the bar closes
Resize Pane (View ▸ Pane, the palette; no key)
pane.resize {pane_id, direction, amount: 0.05} on the keyboard card; herdr moves the nearest divider that way, or returns changed:false when there is none
Move the keyboard (⌥⌘ + arrow; not a write)
no herdr call: the app picks the card from the split tree (Moving the keyboard)
A window opens a session, or lets it go; a new space
workspace.report_metadata {workspace_id, source:"ovvi", tokens:{"ovvi-open": "1" or null}, ttl_ms:30000} (Agent requests)
An agent's ovvi open
as an open from a plugin card: layout.apply with the label and the placeholder in a new tab, then tab.move to right after the asking pane's tab for --tab, else pane.move beside a pane; --replace is pane.rename of the card's label (CLI)
Git state of the space rows (not a write)
no herdr call: git status on the machine (Git status)
Plugin cards' files and git (not herdr writes)
no herdr call: commands on the machine through Exec.run (Commands)
No close can be undone.
Every create and move passes focus:false, so herdr's focus never moves.
Every space reorder is workspace.move_block, which names the
space to go before. The app never calls workspace.move.
pane.move and pane.swap can succeed with changed:false and a reason
(same_tab, zoomed_tab, cross_tab, no_neighbor). The app shows the
reason. Its drag targets avoid same_tab and cross_tab by design
(Drag and drop).
A drop that takes several steps stops at the first step that fails. The
pane stays where it got to, and the notice says so.
After its own write, the app reads the snapshot at once. A new tab, a split,
a tab or space move and a close show before herdr answers: the new tab or pane as an empty card
under a stand-in id, a closing pane, tab or space left out. The
snapshots after the write confirm it (herdr's tab or pane takes the
stand-in's place; the closed one is gone) or take it back (a failed write).
A closing item clears when its id is absent: herdr never gives a closed
id to a new pane, tab or space while its server runs.
Otherwise the app never changes its own state by hand.
Writes of a session run one at a time, in order: a write asked while
another runs waits for it.
A close reads pane.process_info of its panes, at most 8 at a time. When
the last answers say only shells run, the close shows at once while herdr
is asked; a program found brings it back with the question. A pane with no
answer yet waits for herdr's. A pane whose new shell is still starting (its
first seconds, Program icons) runs short-lived
startup programs (ls, sort): it is asked again every 200 ms, up to
0.8 s, until only its shell runs. In a tab made from a stand-in (⌘T), a
close of it still shows at once, so quick ⌘W after quick ⌘T step back one
tab each. The same holds for a pane that a split made (⌘D): quick ⌘W after
quick ⌘D step back one card each. A program found after the wait brings it
back with the question.
A tab closed before herdr made it (⌘T, then ⌘W or a close at once) goes at
once; herdr's tab is closed as it comes. When herdr's tab takes the
stand-in's place, the window's selection and tab history follow it, so a
later close still goes back to the tab used before.
pane.swap makes herdr focus that tab and its pane and mark it seen, even
with focus:false (herdr 0.9.5). The herdr TUI does not jump. Accepted,
because a swap only follows a drag, a split left or up, or a key the user
pressed. With focus:false, pane.move leaves herdr's focus alone.
pane.move into the pane's own tab returns changed:false,
reason:"same_tab".
layout.set_split_ratio: in path, false is the first child and true
the second; the root is []. A path that ends at a pane fails with
split_not_found. herdr clamps the ratio to 0.1–0.9 itself.