Ovvi

Talking to herdr

Running commands, requests, events, reconcile, the terminal plane, control between windows, git status and writes

Running commands

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:
    /usr/bin/ssh -F /dev/null -o ControlMaster=no -o BatchMode=yes \
        -o ProxyCommand=/usr/bin/false -S <ctl> -- <target> <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 on each machine

  • 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

  • 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.

Requests

  • 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).

Events

  • 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.

Agent requests

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):

TokenWhat
ovvi-shown on a panepane.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 panepane.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 spaceworkspace.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).

Chat panes

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.

Reconcile

  • 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.

Terminal plane

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).

What streams

  • 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.

Size

  • 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).

One controller per pane

control can type and resize. observe only watches.

  1. Open as control without --takeover. If herdr refuses (another device holds the pane), open as observe and show "In use elsewhere · Take Back".
  2. 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.
  3. Nothing else takes control. A watcher stays a watcher until its user acts, even after the other device lets go.
  4. A device closes a stream (closes its stdin) when it stops showing the card (What streams). herdr frees the pane at once.
  5. An app that becomes inactive keeps its streams.

Control between windows

Two windows on the Mac can show the same session. Control is per session (D66):

  1. The window where the user last typed, pasted or clicked in a terminal card of the session holds that session's control streams.
  2. 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.
  3. 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.
  4. The session's HostStore records which window controls it, so its windows never take over from each other by themselves.
  5. 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.
  6. Windows on different sessions each control their own session.
  7. Another device can still hold a single pane. That card shows "In use elsewhere · Take Back", as in One controller per pane.

How a stream ends

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 containsThe app
already has an attached clientwatches; shows Take Back
taken overwatches; never takes back by itself
not found or exiteddrops the card; waits for the snapshot
detacheddoes nothing (this device closed the stream)
live updateopens 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.

Git status

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.

Writes are herdr calls

User actionherdr call
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 iconworkspace.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 tabtab.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 downpane.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 uppane.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 dividerlayout.set_split_ratio {tab_id, path, ratio} on release; ratio kept in 0.1–0.9
Double-click a dividerlayout.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 tabpane.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 tabpane.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 capsulepane.move {pane_id, destination:{type:"tab", tab_id, split:"right", target_pane_id}} beside the tab's last card, then equal splits
Drop on the tab track's empty end or +pane.move {pane_id, destination:{type:"new_tab", workspace_id}}
Drop on a space rowpane.move {pane_id, destination:{type:"new_tab", workspace_id}}
Drop a tab on another space's row or menu row, or on New Spaceits 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 +pane.move {pane_id, destination:{type:"new_workspace"}}
Move Tab Left or Right, drag a tabtab.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 spaceworkspace.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 sessionstarts 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, Deleteherdr --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 Sessionno herdr call; the page's store disconnects when no window shows it, and the session keeps running
Close a panepane.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 clientwith 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 HereReplace 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 chatagent.prompt {target: pane_id, text}, only while the agent is ready or working (Sending and stopping)
Stop in a chatpane.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 Chatwhile 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)
Archived Sessions' Resume (Archived Sessions)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 Herelayout.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)
An address typed in a browser cardpane.rename {pane_id, label:"ovvi:browser#<iid> <url>"}
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 Heregit 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 tabtab.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 spaceworkspace.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 Rightone tab.close per tab, all at once; asks once as for a tab, with the total pane count
Rename a tab or spacetab.rename {tab_id, label} / workspace.rename {workspace_id, label}; an empty or unchanged name sends nothing
Rename a panepane.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 pane comes into view or leaves itpane.report_metadata {pane_id, source:"ovvi", tokens:{"ovvi-shown": "1", "remote" or null}, ttl_ms:30000} (Agent requests)
A window opens a session, or lets it go; a new spaceworkspace.report_metadata {workspace_id, source:"ovvi", tokens:{"ovvi-open": "1" or null}, ttl_ms:30000} (Agent requests)
An agent's ovvi openas 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.

What it looks like: Main window, Drag and drop.

On this page