Design decisions
Every design decision, why, and what was rejected
Grouped in the order of the sidebar. Each row is a rule that holds, with a link to the page that has the mechanism, and what was rejected. Numbers are stable: each decision keeps its number, and the number of a dropped decision is never used again.
Source of truth and protocol
| # | Decision | Why | Rejected |
|---|---|---|---|
| D1 | herdr is the source of truth for spaces, tabs, panes and layout. (Data) | Clients with their own copy drifted. A fresh install rebuilds from herdr. | App-owned tabs or layout. |
| D2 | The app reaches a machine through run(argv). Closing a run's stdin ends it. A remote machine's runs share one SSH session through the relay, a Perl script sent with the connection and never installed (The SSH connection). (Running commands) | One path for local and remote machines; herdr's own remote method. A closed stdin ends a child even after an app crash, remote ones too. A server may allow only a few sessions per connection, and a second connection asks for a second passcode. | A daemon or bridge installed on the machine; one SSH session per run (servers refuse past MaxSessions); a release message, a global pid list and a kill at exit. |
| D3 | Every remote command goes through a base64 envelope run by sh. (Running commands) | The login shell may be fish, where POSIX quoting is not safe. | Quoting argv into the login shell. |
| D4 | Control plane = herdr's API socket, on every machine, one connection per request and one for the events stream. On This Mac the app connects to herdr's socket; on a remote machine to a local socket that the host's ControlMaster forwards to herdr's there (ssh -O forward -L). herdr gives the path (session list --json, socket_path), asked once per session (on a remote machine once per connection) and again after a connect fails. A host whose sshd refuses to forward a socket shows an error; there is no fallback. (Requests) | One code path for every machine; no process per request (a bridge run cost two process starts and 11 ms of CPU each, and 63 ms, 1.6 s at p90 on a loaded Mac; the socket 0.4 ms of CPU); herdr's own rule for the path. A forward opens a channel on the connection, not a session, so MaxSessions holds. | herdr --session S remote-api-bridge per request (a process each, on both ends); the bridge over the relay for remote machines only (two code paths); finding the path by hand. |
| D5 | Events only mean "read the snapshot again" (no payload is decoded, except an agent request, D104; status events only for agent panes; no focus events; pane.updated only because a terminal title change can pick a pane's program icon). Reads are paced per session: one in flight, plus a read-again flag; a read again starts 250 ms after the one before started at the earliest, unless a write of the app asked for it. (Events) | One code path; each status entry costs herdr a poll; a read batches the events that arrive during it. | Applying deltas; subscribing to every pane; polling session.snapshot every second; a debounce with a maximum wait. |
| D6 | Snapshots are applied to stable observable objects with hand-written lenient models. Every card is keyed by (machine, session, terminal_id). (Reconcile) | One status change redraws one row. terminal_id survives every move; after a cold restart cards are built again from the machine. | One big state value; code generation. |
| D7 | Terminal plane = herdr terminal session control/observe by terminal_id, for the selected tab in each window (plus D46). Text goes on the stream; special keys and Ctrl chords go as pane.send_keys. (Terminal plane) | Public, per pane, works over SSH, survives moves. herdr encodes special keys for the program's input modes. | herdr's private client socket; terminal attach in a PTY; polling pane.read; encoding special keys in the app. |
| D8 | One control rule: the app opens control without takeover and watches if refused. It takes control (--takeover) on the first key press, paste or click in the card, or Take Back. (One controller per pane) | One controller and one PTY size per pane. Looking pins the PTY to the card size, which is accepted. | Taking over on open; automatic takeover. |
| D38 | Refuse herdr older than 0.9.3; the version comes from the snapshot. The machine's login shell finds herdr once (command -v herdr), and every call runs that absolute path with --session. (herdr on each machine) | Old herdr lacks what we use; lenient decoding needs nothing for newer protocols; the app runs the herdr the user's terminal runs, and PATH stays the user's own; no inherited variable can redirect a command. | A read-only mode; a banner for newer protocols; herdr's install folders in front of PATH (they reordered the PATH of the server and every pane, and needed a trim and env -P); --check. |
| D40 | Every call() times out after 10 s (Requests). One health check per session reads the snapshot every 30 s, every 2 s while it fails; automatic retries never prompt (Health check). | A dead link must not hang writes. The snapshot carries the version and catches missed events. A retry that prompts would show a dialog every few seconds. | No timeout; a timeout on streams; a separate ping, wake check and back-off. |
| D46 | The app keeps the shown tabs' cards plus the 25 most recently shown other terminals (their control streams and surfaces) and the 25 most recently shown other web cards (browsers and plugins, their web views), across all windows, least recently shown first out (spareTerminals, spareWebCards). Floated cards and cards with unsaved edits are never dropped. On critical memory pressure both go down to half; a warning does nothing (macOS sent one every 30 minutes at normal pressure, and each dropped web cards that the next visit made again). A hidden terminal keeps a copy of its last frame; a web card keeps a picture at 1x, under one 200 MB cap for every picture in the app, and a dropped one is made again from its label under it. (What streams, What stays in memory) | Going back to a visited tab is at once; a kept terminal costs about 7 MB of herdr run plus its surface, a kept web card about 28 MB (its WebKit process), and neither costs CPU at rest. At the bound, all processes take at worst about 190 MB + 25 × 30 MB + 25 × 41 MB + ~290 MB ≈ 2.3 GB. 40 + 40 worked too (1.7 GB for all processes), so the bound is memory, which macOS's critical pressure guards. | 4 kept; 16 terminals and every web card kept; 20 + 20; 32 kept; no bound, only memory pressure; purgeable pictures (macOS takes them back within seconds); pictures at pixel size (1275 MB against 833 MB at 1x for 20 browsers); a byte budget. |
| D49 | A Multiplexer Swift protocol, with herdr as its first and only implementation: a deliberate exception to "no abstraction with one implementation". (The Multiplexer interface) | Ovvi is a client for terminal multiplexers, herdr first. One named seam keeps herdr's JSON and argv out of the views. | herdr types used directly by the views; waiting for a second multiplexer before naming the seam. |
| D57 | Where things live: anything that takes space in the layout is a multiplexer pane with a label; transient UI is app memory only; per-device settings are app storage. (Data) | One rule for every new feature; keeps D1 true; a fresh device rebuilds every layout from the multiplexer. | App-only surfaces in the layout (a second layout system); syncing UI state between devices. |
| D66 | Control is per session across windows. When two windows show the same session, the window where the user last typed or clicked in a terminal holds that session's control streams; the other opens observe, dims its cards a little and shows "Controlled in another window · Take Back". Take Back, or a key press or click in a terminal card there, moves control to that window. Windows on different sessions each control their own. (Control between windows) | One controller and one PTY size per pane, as in D8. Per session, so one window never mixes controlled and watched cards of one session. | Control per pane between windows; both windows asking for control (they fight over the PTY size); one main window. |
| D104 | Agents reach Ovvi through one command, ovvi, a sh script that Ovvi writes to ~/.cache/ovvi/bin/ovvi on each machine where it opens a session. herdr is the bus: the command writes its argv to a file in ~/.cache/ovvi/rpc/ on its own machine and rings the pane token ovvi-req; an Ovvi that follows the session takes the file with one mv, parses and runs the command, and writes the answer back as files. Ovvi parses every command but status, which reads the presence tokens ovvi-shown (a pane) and ovvi-open (a space) from herdr alone. A request acts only on its own machine, plugins change only from This Mac, and an agent never moves the user's view: a card out of view gets a notice with Show. (Agent control) | herdr already reaches every machine and every Ovvi that follows the session, so nothing new runs, listens or forwards for it, and the SSH link keeps its forwarding off (the app's own forwards, herdr's socket and the browser's SOCKS, go from the Mac to the machine only: no path back to the Mac). One small script works everywhere and needs no new version for a new command. The mv picks one Ovvi of two. The user keeps control of what they look at. | Ovvi's own socket forwarded over SSH (-R); the ovvi:// URL alone (This Mac only, no answer); escape sequences (herdr owns the terminal); structured JSON requests (the script would know every command); an MCP server (a config per agent); labels, iids and nudges in a skill; a fallback to herdr's popup in the command; presence tokens per device. |
Machines
| # | Decision | Why | Rejected |
|---|---|---|---|
| D15 | SSH = the system ssh, one ControlMaster per host started on purpose, forwarding off except the app's own -O forward (herdr's socket, the browser's SOCKS), an askpass script the app writes beside the sockets. (The SSH connection) | The user's setup works; one prompt per host; no path back to the Mac. | ControlMaster=auto; a Swift SSH library on macOS. |
| D43 | A machine is a host (This Mac or an SSH host) with its own icon; a window shows one machine and its open sessions as pages, and Detach Session removes a page. Sessions on one host share one SSH connection and one relay session (D2). (What a machine is, SSH sessions) | Users think of a computer, not of a herdr server; a session costs nothing until it is opened; one connection keeps one prompt. | One machine per herdr session; every running session loaded by itself; one connection per session; a channel budget. |
| D47 | On This Mac the app starts a session's server as a one-shot launchd job in the user's GUI domain, with the CLI's PATH, HOME, SSH_AUTH_SOCK and XDG_* variables. A remote server starts with nohup over SSH. (Checks on connect) | launchd is the server's parent and responsible process, so macOS privacy prompts never name Ovvi; the server has its own process group and outlives the app; server and CLI agree on paths. | Never starting the local server; launchctl submit (launchd restarted the server after herdr session stop); nohup or a double fork from the app (the app stays the responsible process); a Terminal window running herdr. |
| D54 | The app keeps its own machine list; herdr on the Mac is needed only for This Mac's sessions. (The machine list, Add a machine) | Remote machines work on a Mac without herdr; the askpass dialog answers ssh's questions. | herdr's machine list as the only list, with herdr machine add in a terminal card; an import at first launch; a second list kept in sync both ways. |
| D67 | A space row shows git state like herdr's Spaces panel: the branch and ahead and behind counts, no changed-file count. The app runs git --no-optional-locks -C <root> status --porcelain=v2 --branch --untracked-files=no on the machine through Exec.run (root = worktree.checkout_path, else the cwd of the space's first pane), for a space that is new or whose root changed, when git's own files change (HEAD, refs, packed-refs, config: a long-lived lstat loop in Perl, the same on every machine) and every 60 s, one run per space, only for the page in view of a visible window. When git is only the /usr/bin/git stub without developer tools, the app runs none. A linked git worktree sits indented under its repository's space. (Sidebar, Git status) | herdr's snapshot has no branch. One porcelain run gives the branch and the counts in a stable format; skipping untracked files keeps it fast in a big repository. Runs only for rows that show. The stub would open the "install developer tools" dialog. | A changed-file count; polling every page; a 10 s poll (on a Mac with a wrapped git, 10 processes a run); a git library in the app. |
| D68 | The Open Session panel is the one place to create, start or open a session as a page of the window: a field ("Name a new session, or find one") over a list of New, Running (not open in this window), Stopped, and the sessions already open. Enter or a click on a row takes one path for a new, a running and a stopped session: start the server if it is not running (D47), then open its page last in the window. On an open session it switches to its page. A running session's row has Stop, a stopped one's Delete; both ask first. The footer button, the space menu and File ▸ Open Session… (⌥⌘T) open it. (Open Session) | One place and one name for every way to get a session onto a page. | A session picker in the title row; separate New Session and Change Session commands. |
Layout and terminals
| # | Decision | Why | Rejected |
|---|---|---|---|
| D9 | libghostty-spm draws terminals, at an exact pin. (Dependencies) | Metal, IME and one Swift API. | SwiftTerm; xterm.js in a web view; Ghostty's own libghostty-vt XCFramework (a stable C API, but no renderer: Ovvi would draw the cells itself). |
| D10 | Layout = herdr's split tree rebuilt from the flat snapshot; resize = layout.set_split_ratio. (Layout and sizing) | Ratios live in herdr; no extra calls. | App-owned sizes; layout.export per tab. |
| D11 | Zoom is the app's own: the zoomed card fills its tab's page, drawn by the split tree. (Zoom is the app's own) | pane.zoom moves herdr's focus, marks the tab seen and blocks moves. In the split tree, no terminal view changes host and no other PTY resizes. | pane.zoom; a separate layer (its terminal view did not come back on restore). |
| D12 | One tab = one page; the window shows the selected tab's page, and a switch replaces it at once, with no motion. The pages of the 8 tabs shown last stay made under it, hidden, with their views hidden, also under the overview, so a switch to one builds nothing and its cards keep their state. (Tab page) | Matches herdr and Cube; an instant switch costs nothing per frame, and the pages' slide made much of the window's code. A page made again at each switch lost a chat's scroll place and Show Earlier's rows and cost its content's build at every switch: among a chat, a long Markdown file, a Git diff and a terminal, a switch took a median 279 ms of the app's time on CI, and 110 ms with kept tab pages. A hidden page costs only its cards' updates. | Panes wider than the window in one long scroller; a strip of pages that a swipe or a switch slides (its scroll view updated the whole window at every frame); each page made again at every switch; a host view per chat that the window keeps (SwiftUI built every row again whenever the host moved). |
| D13 | The app never calls herdr's focus methods; selection and "seen" are the app's own, per device. (Selection and "seen") | Each focus call moves every attached herdr TUI. | Focusing on click or swipe. |
| D14 | "Finished, not seen" = completion_seq not null and different from the seen value; the first sighting counts as seen. Looking at a pane (the keyboard card on the page in view of the last key window, also while the app is in the background) and then leaving it records it seen. Seen values live in memory in the session's store, shared by its windows. (Selection and "seen") | herdr skips done when a TUI watches the tab; the counter restarts with herdr. | Waiting for done; a "greater than" test. |
| D41 | Closing a pane asks first unless the pane runs only its shell. Closing tabs or a space asks only when a pane of them runs a program besides its shell. The dialog names what runs; its action button is red; Return confirms, Esc cancels. (Writes) | A close kills processes on every device and cannot be undone; a pane with only its shell loses nothing. | Asking only when an agent works; always asking for tabs and spaces; never asking. |
| D42 | Tabs act like Rex's: 220 pt each on a capsule track that scrolls when they do not fit, 1 pt dividers, one selection marker that jumps with every change (no motion when a tab opens or closes), and × on the hovered tab only. The tab's strongest agent shows one still icon in the status slot, on every tab, the selected one too: needs you, then finished, then working (t3code's icons and colors); no badges on the program icons. Dividers have a 16 pt hit area and a 36 × 3 pt grip in the pane buttons' color on hover and drag; a double-click sets 0.5, ⌥⌘= balances the tab, and with Balance splits automatically on (the default) every pane that opens or closes evens out its row or column; off, splits halve as in herdr's window. (Main window, Layout) | Rex is the reference look, measured from Rex; the user found moving dots and spinners distracting, and one still icon reads at a glance. gorex stays the readable source for the slot and divider behavior. | Three working dots in step (15 fps) plus 7 pt badges on the front icon; a spinner; several glyphs per tab. |
| D64 | No card fades: every card is drawn at full strength, in key windows and the others. Only the keyboard card's fill and stroke set it apart, and in Glass only a little. A watching window's 0.85 dim (D66) stays. No setting. (Layout) | Faded cards and Glass's darker card fill sat too far apart from the keyboard card. | Rex's content fade (0.7 for other cards, 0.5 and 0.8 in a window that is not key) with View ▸ Fade Inactive Panes to turn it off; a black overlay over unfocused cards. |
| D69 | Drag and drop offers only what herdr allows, and builds the rest from herdr calls: a swap only inside one tab; a move into another tab beside a card; a drop on an edge of a card in the same tab is a move to a new tab, a move back beside the target and, for left or top, a swap. Tabs reorder inside their space; a tab dropped on another space or on New Space moves there pane by pane, its split tree and ratios rebuilt. Spaces reorder with workspace.move_block (a space with its linked worktrees). A pane moved into a tab gets an equal share of its run of splits, as after a split. Nothing moves between sessions or machines. (Drag and drop) | herdr 0.9.5 refuses a swap across tabs (cross_tab) and a move into the pane's own tab (same_tab), splits only right or down, and moves tabs only inside a space (layout.apply makes new terminals). Every step stays a herdr call (D1). | Only swap inside one tab; drops between sessions. |
Pane kinds
| # | Decision | Why | Rejected |
|---|---|---|---|
| D71 | Every pane the app creates shows a chooser first: Terminal (selected, so Return gives a terminal), Agent (its four agents open in place), Browser, File Explorer, Git or another plugin with a chooser row. Settings ▸ General can name one kind that every new pane opens instead; ⌥ held as the pane is made shows the chooser. The shell starts at once behind it; the chooser is the app's own state. An agent is ready when herdr reports it with a status other than unknown. (New pane) | A pane says what it is for in one key; a quick terminal stays one key away; herdr reads agent status from the screen on every machine. | A shell shown at once; a glass panel with tiles and a second step for agents; a ready signal from frames going quiet. |
| D72 | A browser pane is a herdr pane labeled ovvi:browser#<iid> <url> running a placeholder, drawn by the app as a WKWebView with Web Inspector, one WebKit store per machine, its page and history in a state file on the machine, and a page from the machine loading by itself only from an approved or safe origin. An open card follows its label. (Browser) | The label survives moves and cold restarts and every device and the TUI see it; WebKit costs no app size; agents can open and change pages with herdr alone. | Chromium through CEF; app-only tabs; loading every label by itself; a card that ignores renames until rebuilt. |
| D73 | A remote machine's browser pages go through one SOCKS proxy per machine: the app's own front on a loopback port before ssh's -D on the ControlMaster, with localhost loaded as <machine>.localhost and every other loopback request blocked. (Remote localhost) | One forward covers every port and name; refusals come at once; the page never reaches This Mac's loopback; nothing is installed on the machine. | A forward per port (clashing ports, changed origins); ssh's -D alone (60 s waits); a server helper. |
| D88 | A link that asks for a new window (⌘-click, target=_blank, the context menu's Open ‹Link› in New Tab) opens a browser pane in a new herdr tab, in the background, its origin approved. A page's own window.open (an OAuth popup) stays over the card, on glass, closed by Esc or its ×. No download items. (Popups, links and dialogs) | Links open as in Safari; the new tab is a herdr pane every device sees; a popup needs its opener. | WebKit's popup sheet for every new window (it could not be closed over a white page); loading target=_blank in the card; download items that save nothing. |
| D74 | The chat view is only for agents Ovvi starts with its flags (from the chooser, or again with Relaunch with Chat, D83): their own history, followed on their machine, plus agent.prompt to send; a pane is known by a launch marker in its foreground argv (pane.process_info). (Chat view) | The history is the agent's own record, so it is exact and survives a relaunch of the app with no state; the same code works over the relay. | A chat for every pane from its screen text (it breaks with each agent release); restarting the agent in a structured mode (codex --remote with an app-server, pi --mode rpc, ACP, the Agent SDK), which adds a process or a server for nothing the hooks do not give; live text word by word. |
| D75 | A small core, and the live terminal for the rest: the chat answers only Claude Code's single-choice questions and OpenCode's permissions and questions; tool approvals, other questions, dialogs and pickers show the live terminal at the bottom of the card. (Waiting) | Every case stays answerable without a chat answer for each. | In-chat tool approvals: in Claude Code a managed hook that allows beats Ovvi's deny, and a waiting hook keeps prompts open that would close by themselves; in Codex a waiting hook runs before its reviewer and hides the TUI's prompt. |
| D76 | The chooser types a launch line into the pane's shell: the command with inline hooks or a plugin, no quote or backslash in a hook, side files in ~/.cache/ovvi/chat/T/ (0700) that each launch sweeps after 30 days untouched; the bare command once more when the shell comes back at once. (Launch flags) | The user's alias or function still runs; one line works in sh, bash, zsh and fish; nothing is written to the user's config; a build that refuses the flags still starts. | A settings file per launch; /tmp (shared between users); env … sh -c 'exec …' (skips the user's alias); writing hooks into the user's settings; a bypass flag for Codex's hook review (its trust hash instead). |
| D77 | pi-ovvi ships inside the app and is written to the machine named by its content; Pi's phase is its state file alone. (Launch flags) | The user may not have pi-hkandala; two app versions never fight over one file; the state file works on remote machines without herdr's pi integration. | An extension in pi-hkandala; herdr's status for Pi's working and idle. |
| D78 | OpenCode's chat goes through its own server on 127.0.0.1 with a password per launch from a 0600 file, reached with curl on its machine; a build that never reports (an OpenCode build that ignores plugins) is told by what it does: no state line in 15 s flips the card to its terminal. (Where messages come from) | opencode writes no transcript file; the password keeps other local programs off the port and never shows in argv or the scrollback. | command -v opencode up front (the app's shell is not the pane's); opencode 2.x's shared service. |
| D79 | The chat looks like Messages, with no status island: a 760 pt column on any card with roomy Markdown, the user's messages and the agent's replies in the blue and gray bubbles of macOS Messages, a reply box that grows to 10 lines with the model and effort under the text and Send always in the accent, the reply box on glass over the bottom of the messages, every message of a turn with no detail setting, each run of tool calls between two messages as one closed quiet line in pi-content-layout's words whose rows are made only when it opens and whose calls open to their input and output, and thinking as a step of its run. (What it looks like, Messages) | Long replies read well on a wide card; the replies get the attention, not the tool calls; a long chat lays out no rows for its thousands of tool calls; the messages show under the glass, as the user asked; one wording in the chat and in pi. | A web view; the level names Steps and All (they did not say what shows); a glass card per run of tool calls (as loud as a reply); three detail levels (Replies only, Activity, Everything) in a pill beside Chat | TUI (the user wanted every message and no setting); the level as three segments beside the reply box; a status island; a Show: pull-down (its label said less than the pill); replies as plain text (they did not read as one side of a conversation); a gray Send in an empty chat; a swipe between chat and terminal. |
| D80 | A browser card, a plugin card and an agent card have two header rows: row 1 has the title and the card buttons, as a terminal card's one row; row 2 has the kind's controls (the browser's toolbar; a plugin's Back, Forward and its page's controls; the agent's name and folder, its state as a symbol and a word, then Chat | TUI or Relaunch with Chat). (Pane card) | Every card keeps its title and buttons in the same place, and the kind's controls get the full width. A terminal keeps its height. | The browser's toolbar as its whole header; the agent's state as a capsule on row 1 (it did not line up with the title); the agent's controls squeezed beside its title; two rows for every card. |
| D81 | A browser card has no list of the machine's listening ports: the user types the address. (The card) | The list added little over typing localhost:3000, and cost a run with process lookups on the machine each time it opened. | A ports chip that listed the listeners, grouped by space. |
| D82 | An agent pane is named by its own session's title: the user's herdr label, else the title the agent keeps (read on its machine with herdr's agent_session), else its first typed prompt cut at 100 characters, else "Untitled"; in the card, the floating window, the tab and the sidebar. (Session titles) | Claude Code, Codex and OpenCode name sessions themselves, and pi's /name names one; the user's own label still wins. One small read per change, on any machine. | Titles the app makes itself; writing the title into herdr's label (it renames the pane for every device); a scan of the agent's files for a session herdr does not report. |
| D83 | Relaunch with Chat starts an agent that runs without Ovvi's flags again with them, in the same session: Stop if needed, ⌃C, the agent's quit command, then the launch line with the resume arguments, made before the agent quits. It asks first while the agent works or waits. (Relaunch with Chat) | The chat needs the launch marker and hooks that only a launch adds; resuming keeps the conversation; a failed run on the machine leaves the agent running. | A chat from screen text (D74); a bare-command fallback (it starts a new session); a session found by scanning files; a restart with no question during a turn. |
| D84 | A chat pane opens on its TUI; the user switches to Chat (Chat | TUI). After Relaunch with Chat it opens on the chat. (Switching views) | The user asked for the terminal first; Relaunch with Chat is itself a request for the chat. | The chat first; a setting for the first view. |
| D85 | A chat reads a history file whole, from its start, and shows it once read up to where it ended at the open; the backlog is parsed off the main thread, a run of lines at a time, with a small spinner in the empty chat meanwhile. On the pane's machine awk drops the lines the agent's parser skips, and counts their bytes. (Where messages come from) | One Claude Code turn can pass 256 KiB, so a tail showed no earlier turns; the filter keeps most of a transcript (Claude Code's api-request-* records) off a remote machine's relay; a whole first read does not show row by row; a 108 MB session took seconds and half the main thread when parsed there; off it, the window keeps its frame rate. | The last 256 KiB; a parser on the machine; parsing on the main actor with a yield every 200 lines (the window stuttered while a long session loaded). |
| D86 | A reply's Markdown is parsed by Apple's swift-markdown (cmark-gfm) and drawn with SwiftUI: tables, task lists, strikethrough, nested lists and quotes, rules; bare web addresses linked with NSDataDetector; an image shown as its linked description, never fetched. (What it looks like) | A known GFM parser covers the standard syntax with no hand-made parsing; a fetched image could send what the agent read to any server. | AttributedString(markdown:) (no tables or task lists); a parser of our own; a web view. |
| D89 | One plugin, many places: a plugin card moves between places in one card and one page, and the herdr label (ovvi:<id>#<iid> <arg>, running a placeholder) follows the place. The plugin draws every place itself and moves with card.go; the app keeps Back and Forward per card and draws them when the manifest says "history": true; on("place") tells the page where it is after Back, Forward or a rename from elsewhere. A click opens in the card, ⌘-click in a new herdr tab right after the card's tab (layout.apply, then tab.move), ⌥-click in a pane to its side (pane.move after layout.apply). (One plugin, many places, File Explorer and Git) | The label survives moves and cold restarts, and every device and the TUI see it, as for the browser (D72). One card per place filled the session with tabs; exploring in one card, with the other two gestures one modifier away, keeps the card the user came from only when asked. The app's Back and Forward look the same for every plugin and work from ⌃- ⌃⇧-. | One plugin per place (five kinds, the user asked to merge them); the web view's own history for Back; a page loaded again for each place (a tree card would load CodeMirror's chunks at each move); a ⌘K Go to File. |
| D90 | A plugin card's content is a web page of its own package, one WKWebView and one WebContent process per card. File Explorer uses CodeMirror 6, @pierre/trees, markdown-it and pdf.js, Git uses @pierre/diffs, each from its own node_modules. The header row, the toast, the menus and Open With stay native, and all file and git data stays in HerdrKit's Files and Git over Exec, reached through the plugin API. (How the cards are built) | Recognized editors and viewers give syntax colors, word diffs and a fast tree with little app code. Native data keeps saves, hashes and git in one tested place for This Mac and remote machines. | Native views (TextKit 2 with SwiftTreeSitter, PDFKit, AVPlayerView, NSOutlineView), built with the same features; a framework (React, Svelte); a shared process pool (WKProcessPool does nothing); the page running file and git commands itself; a bundle checked into git. |
| D91 | A plugin page loads only from ovvi-plugin://<id>/, served from its entry's folder alone with no subfolders, under a Content Security Policy that allows only that plugin's own scripts, styles, fonts and fetches, and navigates only to its own page. It reaches the app only through the ovvi object, on the card's own machine. (Security and trust) | loadFileURL runs no module scripts. A Markdown file's HTML (a <meta> refresh, a form) took the card, and the bridge with it, to any site. One origin per plugin keeps one plugin's files from another's page. | loadFileURL; one shared ovvi-plugin://app/ origin for every plugin; a fixed bridge of asks and reports per kind; a global window.ovvi. |
| D92 | A save is checked by the file's cksum from the last read (files.write with over) and written as a new file moved over the old one. A change on disk reloads a clean file in place and gives a dirty one a toast (Reload, Keep Mine); a conflict at save gives Overwrite or Reload. Files are watched only while their card shows: FSEvents on This Mac, a 2 s check on a remote machine. (Edits, saves and reloads) | No silent loss either way: the user's edits and another program's writes both survive until the user picks. A hidden card costs a remote machine nothing. | Saving over whatever is on disk; reloading over edits; a dialog for every change; watching every card all the time. |
| D93 | Over a web card, ⌘ keys go to the menu first, as over a native text view. Save (⌘S), Find Previous (⇧⌘G) and a plugin's own commands (Lock Editing, ⌘E) have their key only while a card that uses them (handles, commands) has the keyboard; elsewhere ⇧⌘G stays Go to Directory. ⌘[ and ⌘] select a pane over every card; a plugin card's Back and Forward are ⌃- and ⌃⇧- (VS Code's), taken by the card's view before the page. (Keys) | WebKit gives the page every ⌘ key first, and CodeMirror took the app's ⌘[ and ⌘]. Back on ⌘[ ⌘] clashed with pane selection. A disabled menu item swallows its key, so an always-present ⌘S or ⌘E would eat it on every other card. | The page first for every key; ⌘S and ⌘E always in the menu; a Find Previous key everywhere; ⌘[ ⌘] as Back and Forward over a plugin card (it hid pane selection there); Xcode's ⌃⌘← ⌃⌘→ (window managers and other apps take them). |
| D94 | The Git plugin runs git's everyday writes from its home, through the API's git writes: Stage, Unstage (one file or all), Commit (the staged changes; Commit All stages every change first when none are staged), Fetch, Pull, Push, a branch switch, Revert (one file or all, after a question that names the new files it deletes) and Initialize Repository (git init) for a folder outside a repository, one at a time, on the card's machine, with GIT_TERMINAL_PROMPT=0. Pull is plain git pull, Push sets the upstream for a new branch (push.autoSetupRemote). A failure shows git's own message in the card's toast. (Writes) | The user asked for basic git as in VS Code's view, next to the changes and the history. git's own rules (pull.rebase, hooks, signing) still apply, and nothing can wait on a password prompt that no one sees. | Revert with no question (it loses work); a rebase or merge option on Pull; our own credential prompt; a terminal opened for the command. |
| D95 | A chat lays out a block of its newest rows in a plain stack, not a lazy one: the rows that hold about 120 lines of text, at most 34. Show Earlier adds the next block above without moving what is in view. The scroll view is made only with the rows, at the bottom. (What it looks like) | Every row's height is real, so an open, a split and Scroll to Bottom land exactly, with no blank. Each time the card is made (its tab first shows, or its page was not kept) it builds and lays out the block, so the block's text is that show's cost: 34 rows of long replies took 394 ms of the main thread, 120 lines (about two views) take about 124 ms, a terminal tab 34 ms. 100 rows took 110 to 160 ms at a split's end. At the bottom the stack is the newest block; once the chat leaves the bottom, rows shown stay until the card is made again or shows its TUI, so the stack grows with Show Earlier and new rows, as the user asked. The bottom is held by the scroll view's bottom anchor alone. | A lazy stack (it guessed heights by up to four times and showed a blank after a split); the chat kept in a host view of its own between shows (SwiftUI built every row again whenever the host view moved); every row in a plain stack (680 ms to open a long chat); a scroll view made empty (its top inset stayed above a long history). |
| D96 | A plugin is a package, as pi's are: a folder with a package.json whose ovvi key is the manifest (with an id of its own and an entry), its source and its own node_modules; its version, description and keywords are the package's. Sources are npm:<spec>, git:<repository>[@ref] or a local path, listed in ~/.config/ovvi/settings.json (packages). npm and git sources install into ~/.config/ovvi/plugins/ with the npm, pnpm or bun on the user's login PATH, with no install scripts and no peer dependencies; a local path loads in place. Install, Update, Reload and Remove live on the Plugins page. (Packages and installs) | The user's model is pi's packages; one file holds the dependencies and the manifest, and the app reads it before any code runs. The user's own package manager keeps the app small and the lockfile authoritative. | A folder with a manifest.json (two files, and npm habits anyway for dependencies); a manifest as a TypeScript export (code must run to fill the chooser); a package manager inside the app; a plugin store. |
| D97 | A package builds itself: entry is built JavaScript (dist/main.js, with its chunks, CSS and assets in one flat folder beside it), and the app runs only the package's own build script, at install when no built entry is there, on Reload Plugin and on the card's Build. Reload Plugin, not a watcher: it reads the manifests again, builds, and loads each card's page again with its state and header values; a failed build keeps the last good dist and the running page. A template (plugins/builtin/template, New Plugin…) holds the scripts (build, dev, check), and File Explorer and Git use its layout. (Build a plugin) | The user asked for own build scripts and a standard boilerplate: any author or agent with Node and npm can build a plugin, with any tools, and the app ships no bundler. An explicit reload lets an agent save many files and a user choose when the card changes. | esbuild shipped in the app and a build cache (10 MB more app, a resolve rule to keep a build inside its package, and one bundler for every author); TypeScript loaded as is; a file watcher with a quiet time; hot module replacement. |
| D98 | The ovvi API is host-provided: the runtime makes the object and passes it to the entry's default export; a plugin lists @ovvi/plugin-api in peerDependencies as "*" (optional) for its types and never bundles it; the app writes the types package into the plugins folder's node_modules at launch. The API is versioned by one integer, api. (The plugin API) | Types document the API, and the app knows when the card is ready (main returns). A host object means one runtime for every plugin and no API code baked into builds. One integer means one check. | A global window.ovvi (no ready signal); an imported runtime module bundled into each plugin; raw postMessage; a semver range or a minimum app version. |
| D99 | A card's header row is declared in the manifest and drawn natively: button, toggle, segmented, menu, field, text, spacer and openWith, plus Back and Forward from history. The page changes a control's value, title, symbol, label, badge, items, a path's links, hidden, enabled or busy state with controls.set, so one header serves every place. Controls sit 6 pt apart; gap sets the points before one (0 joins a group), and "digits": "mono" gives a count fixed-width digits. (Header) | Native controls look like every other card, draw before the page loads and keep their place, and keys and VoiceOver come free. gap and digits let a plugin group its controls and keep its counts from jumping. | Controls drawn by the page (they look unlike the native cards and show only after load); both declared and page-drawn areas; a fixed spacing the plugins cannot group. |
| D100 | The default plugins are ordinary packages: the app carries their source and installs each into the plugins folder on first launch (a copy, npm ci, its build), where the user can edit them. An app with a newer version replaces an unedited copy; an edited copy stays until the user picks Update to Version or Reset to Default, which moves it to the Trash. They never ask for a confirmation. (The default plugins) | File Explorer and Git test the public API, and the user or an agent can change them as any plugin. Hashes of the source and the copy tell an edit from an install. | Defaults bundled into the app's build (special, not editable); replacing an edited copy on update; a copy per app version. |
| D108 | No permissions: a plugin can use the whole API on the machine of each card the user opens. A plugin asks once before its code or build script first runs (an install or a local path from the Plugins page or an agent, a hand-made folder or a settings.json path in its first card), with its id, version, source and folder, its description, the local servers its page connects to (connect), and "Plugins run code as you on this Mac and on the machines whose cards you open, including their build script. Install plugins only from sources you trust.", then Install or Cancel. Cancel closes the pane; Remove forgets. trust.json lists the confirmed ids. New Plugin… and the defaults never ask. (Security and trust) | run, git and files:write each let a plugin do anything as the user, so permissions promised a limit the app could not keep; the user asked for one honest question instead. A code change does not ask, because Reload depends on that and a program that can edit the code can already run as the user. What the app does protect, content from files and machines taking over a card, needs no permissions (D91). | Permissions in the manifest with a sentence each and a new prompt when they grow (they read as a sandbox that was not there); a sandbox per plugin (a separate process with no run, which would break the default plugins); no prompt; a prompt on every code change. |
| D102 | A plugin that cannot load says why in its card, in plain words with the fix as a button and Show Log: needs Node.js, could not install, did not build, is not built, stopped, needs a newer Ovvi, did not load (a bad manifest). The same words, build output, console lines and uncaught errors go to ~/Library/Logs/Ovvi/plugins/<id>.log, reachable from the card's menu, the error card and the Plugins page. (Errors and logs) | The user asked for relevant errors and one place for plugin logs. Agents read the log file directly. | A blank card; errors only in the Web Inspector; the unified log alone (agents and users do not read it). |
| D103 | One registry for every kind the app draws over a placeholder: the browser, with a manifest written in Swift, and each plugin package, first id wins (the browser, the defaults, settings.json's local paths, then the plugins folder). The chooser, ⌘K's Open ‹chooser› Here, Open With and opens read it. A file's default plugin is picked by its name alone: the most specific pattern, then a user's plugin before a default one, then the lower id. (Where the registry looks, Which plugin opens a file) | One list for every kind; the browser keeps what only a native web view gives. Picking by name reads no remote file and gives a fixed order. | Built-in kinds as a Swift enum beside plugins (every list with two sources); the browser as a framed page; a function in each plugin that says yes or no for a file. |
| D107 | Plugins declare settings in their manifest (ovvi.settings: toggle, choice, number, text and path, each with a default, at most 16). One value counts for every machine. settings.json keeps them under plugins, only the values that differ from the default. The app draws them on the plugin's page in Settings; the plugin's pages read them (ovvi.settings.get, API 3) and hear each change (the settings event) but never set them. A card's own header choice, in its state, wins over a setting, which starts new cards. A setting is a value, and there is no secret type. (Settings) | Plugins get settings without code for a settings screen, and the app draws them as its own. Read only keeps the user the owner of settings. One value for all machines is enough for every setting the default plugins have. | Settings pages that plugins draw; a set call; a value per machine; a secret type (a plugin runs as the user and could read it anyway). |
Status
| # | Decision | Why | Rejected |
|---|---|---|---|
| D31 | Notifications are made by the app from snapshot changes, for every open page, never for the panes of the tab shown in the key window, with no sound. (Notifications) | herdr sends its own only to TUI clients; no double alerts. | herdr's private notifications; dropping banners. |
| D50 | A pane floats in a window of its own, made by Ovvi, while herdr keeps it in its tab (its card there is a placeholder); ovvi://float floats one from a Mac tool, and ovvi float from an agent on any machine (D104). (Floating pane) | herdr has no floating pane its API shows; keeping the pane in its tab costs no herdr call and changes nothing for other clients. | A floating card inside the main window; a tab labeled ovvi:float; SwiftUI restoring the windows on its own. |
| D55 | Agent status comes only from the multiplexer's API; under herdr the app never parses OSC 7501. (Status) | herdr's terminal stream is a redrawn screen, so the sequence never reaches the app; one source keeps every device in agreement. | Parsing OSC 7501 in the app's libghostty surfaces under herdr. |
| D105 | Three notification switches in Settings ▸ Agents, each on by default and a key in app storage: When an agent needs you, When an agent finishes and When an agent sends one. The last covers ovvi notify, which then answers "notified": false and exit 0. An agent's notification is limited to one per asking pane every 10 s, and a click shows the pane. (Notifications, Settings) | Agents can post notifications, so the user needs a way to stop them apart from the app's own; with three switches, the needs-you and finished banners can go too. Off is not an error: the agent has nothing to fix. | One switch for every notification; the needs-you and finished switches fixed and agents' under one of them; a menu bar submenu; rate_limited or an error while the switch is off. |
| D111 | One sidebar, no second view: the space rows, the only part that scrolls, each in a border (the selected one in glass) and, unfolded at first, with its agent panes inside, one line each with the state box and working time, by tab and place in the tab; a chevron folds them. Pinned spaces on top, then a line (a "Pin Space" region while a space is dragged), and Show Only Pinned Spaces hides the others; Sort Spaces, each session's: Manual (herdr's order) or Recent (the space whose agent changed state last first, held still under the pointer; "Recent", not "Activity": the shortest word for what moves a space up). Under the pages, which they do not slide with, the page dots and the machine's Archived Sessions (D110). (Sidebar) | One place for spaces and agents, as herdr's Spaces panel plus agents; a list grouped by space needs no settle rule to stay short; Archived Sessions is the machine's, so it does not change with the page. | The Agents view (one list by recency, with Pinned and Settled); a switch between two views; a New Agent button on top; pins on agents or tabs. |
| D110 | The sidebar's Archived Sessions, a folded shelf at its foot, lists the closed sessions of Claude Code, Codex, Pi and OpenCode from the agents' own stores on the machine, read only, newest first, 50 an agent a page, one shell script per agent and page; a click resumes one with the agent's own resume flags in a new pane in its folder. (Archived Sessions) | herdr keeps nothing about closed panes, and the agents already keep every session with its folder and title, also the ones started outside Ovvi. A page per open and one process per page keep the cost low on a machine with thousands of transcripts. | A record of its own of every agent pane the app saw close (it misses sessions started elsewhere or while the app was closed); one process per transcript file; reading every page at once; a count in the shelf's header. |
Look and platform
| # | Decision | Why | Rejected |
|---|---|---|---|
| D44 | Appearance is Auto, Light or Dark (Dark by default), and the interface style is Glass (a behind-window blur) or Classic (an opaque window); one small token table per style and appearance holds every color, measured against Rex's screenshots. Pane cards never get glass; terminals use one light and one dark palette that follow the appearance. (Theme and accessibility) | Rex's two styles cover both tastes: the desktop showing through softly, or a plain window. A fixed token table keeps the look small and checkable. | Dark only; glass on the cards; a glass sidebar panel; Rex's 41 themes, a theme editor, theme import and "Tint with theme"; a compact density. |
| D45 | One title row, always the same: traffic lights, the sidebar toggle (always shown; its icon shows open or closed, with no highlight), the machine button (the machine's icon only), the space button while the sidebar is collapsed ("‹page title› | ‹space›", with a menu of every space grouped by session), the selected space's tab bar, then the overview button (D70), ⌘ and + on the right; 46 pt tall with the traffic lights at Rex's place. The tab bar is always in the title row; the sidebar lists spaces and their agents (D111). (Main window) | Tabs stay in one place whether the sidebar is open or not; the sidebar lists spaces as herdr does; an icon is enough for a machine the user rarely changes. | Tabs as sidebar rows while the sidebar is shown, and the tab bar only while it is hidden; a session picker in the title row (sessions are sidebar pages); the machine's name on the button; a tab placement setting (Rex). |
| D48 | ⌘K (and the title-bar ⌘ button) opens the command palette; ⇧⌘P also opens it (Rex's key). It lists every command with its shortcut, tabs, spaces and the sessions open as pages, fuzzy-matched as you type. Choices and names open sub-pages, and each setting is a command that says what it does ("Turn On Focus Follows Mouse"). (Main window) | ⌘K is the palette key in Slack, Linear and VS Code's quick open; ⇧⌘P is Rex's. herdr owns the screen, so Terminal's ⌘K (clear) has nothing to clear. One command list feeds the palette and the menu bar, so a title or shortcut is written once. | ⌘P; a palette that only jumps; ⌘K for Change Session as in Rex. |
| D34 | One macOS 26 app target. The user can open several windows (File ▸ New Window, ⌘N). Each window shows one machine and keeps its own selection; windows on one session share its HostStore. (Project) | Liquid Glass with no fallbacks. Two sessions or two machines side by side; per-session control (D66) keeps one controller per pane. | Back-deploying; one main window. |
| D35 | A sideways gesture over the sidebar pages its session pages; any other goes to the view under the pointer; with ⌥ held, a swipe steps sessions and the sidebar's rows; the PaneViewRegistry owns every view. (Swipes and views) | Swipes do not change tabs, so the content gets its own scrolls; SwiftUI would destroy views. | Sideways swipes that page tabs (they took every sideways scroll from terminals and web views); each view handling its own scroll; ⌃ or ⇧ + scroll for sessions (the system's zoom; sideways scroll); three-finger swipes (the trackpad's touches: unreliable, and they need the system's three-finger gestures off). |
| D60 | Program icons come from our own catalog of open-license SVGs (resources/icons/). A pane's icon is matched in this order: herdr's agent, the foreground process, the terminal title, then the shell; the app asks herdr for the process once for every pane when it first appears in a snapshot, when it shows in a card, 1 s after Return in its card and when its title or agent changes, never on a timer. (Program icons) | A tab shows what runs in it at a glance, as in Rex. Asking only on these events costs one call each and catches almost every program change. | Rex's icon art; one >_ tile for every pane; polling pane.process_info. |
| D106 | A Settings window (⌘,), as macOS System Settings: a SwiftUI window with a sidebar of seven topic pages (General, Appearance, Terminal, Agents, Machines, Plugins, Privacy) and a grouped form per page; a machine and a plugin open a page of their own from their list. Every page always shows in the sidebar. A search in the sidebar finds a setting and rings its row. Settings keep their keys in app storage per device; the menu bar keeps the quick switches (View ▸ Appearance, Interface Style, the text size, Focus Follows Mouse, Balance Splits Automatically), and plugins are set only in the window. Only settings that work are in it. (Settings) | The user asked for a window grouped like System Settings, minimal but complete. Topic pages stay seven rows however many plugins and machines there are, and keep a plugin's settings, log and Remove on one page. Native grouped forms with picture tiles and program tiles meet the design bar. | No Settings window (the menu bar only); four large pages; a sidebar row per machine and plugin; a page per kind of card; one scrolling page; placeholders for features not built. |
| D63 | Resting the pointer 0.25 s on a title-bar tab or a sidebar agent row shows the hover card: the tab title and one row per pane; a click on a row goes to that pane. For a pane that needs you it shows the last 3 lines of its screen, read with pane.read when the card opens, because herdr 0.9.5 sends no blocked message. (Hover card) | See what runs in a tab, and why an agent waits, without leaving the current tab. One read per blocked pane, only on demand. | A live tab preview in the card; Rex's blocked message (herdr has none); reading screens before the card opens. |
| D70 | The overview (⌃⌘O) shows every tab of the session as a miniature of its page, grouped by space, drawn from pictures: each terminal's snapshot and each other card's last picture, taken for the cards in view as it opens and kept from the last show for the rest (a chat's from the window's layers as it leaves view). A terminal with no snapshot yet streams until it drew, under a cap. It shows the session in view and opens at once; leaving, the selected tab's tile grows to its page in 0.18 s while the sidebar and the title row fade in. (Overview) | Many agents at a glance, whatever a tab holds, at almost no cost to the window: it opened in 205 ms with 20 + 20 kept cards, 327 ms with live views. A tile takes no view from its card, so the card never draws blank as it comes back. | The cards' own views, scaled, live in the tiles (a web view moved into a tile drew blank on its way back, and live tiles cost frames); a small observe-only surface per pane; text from pane.read; SwiftUI's scaleEffect for terminals; cacheDisplay for a chat (it drew SwiftUI's content blank); a globe for a browser; the sessions' pages side by side, paged by a swipe; leaving at once (the user missed the zoom). |
| D65 | When a tab's herdr label is herdr's default (a bare number), the app shows the keyboard card's program and its folder's name as the tab title ("zsh ~", "pi ovvi"); for an agent its name and its pane's name ("Claude Code Fix login", D82), for a browser "Browser" and its page, for a plugin its name and title ("File Explorer ovvi"). The app never writes that title to herdr. (Main window) | A number says nothing; the program and folder do, as in Rex. Writing it would rename the tab for every device and herdr TUI. | Showing the bare number; renaming the tab in herdr by itself. |
| D87 | Where a title is plain text (⌘K, the Window menu, Move Pane to Tab, the window title, a close dialog), a | separates its two parts: "Browser | Hacker News", "Claude Code | Fix login", "zsh | ovvi". The tab capsule, the hover card and the overview keep the dimmed second part. (Tab bar) | Without the dimmed part, a space ran the two parts together ("Browser Hacker News"). | A space. |