CLI
The ovvi command: every subcommand with examples, its JSON output and errors, and the script itself
ovvi is a POSIX sh script of about 70 lines
(Agent control). It carries its arguments to Ovvi and
prints the answer (Protocol). Ovvi parses
every command except status, which the script answers from herdr alone.
Where it is
~/.cache/ovvi/bin/ovvion each machine where Ovvi opened a session, This Mac included. Ovvi writes it when it opens a session on the machine and the file there is missing or older (How the command gets there).- It is not on
PATH. Agents use the full path (theovviskill names it: The ovvi skill). A user who wants the short name addsexport PATH="$HOME/.cache/ovvi/bin:$PATH"to their shell. - No file means that no Ovvi ever opened a session on this machine.
- It needs
sh,sed,date,mv,rm,cat,mkdir,head,tail,tr, asleepthat takes fractions of a second,uuidgenor/proc/sys/kernel/random/uuid, and herdr at$HERDR_BIN_PATH(herdr sets it in each pane) or onPATH.
Usage
ovvi [--pane <pane>] [--timeout <seconds>] <command> [<args>] [--json]--panerings another pane of the session than$HERDR_PANE_ID. Use it for a caller outside a pane, or after the pane moved to another space: herdr then gives it a new id, and the environment keeps the old one.--timeoutis how long the command waits for Ovvi's answer once Ovvi took the request, in whole seconds. The default is 10. Giveplugins new,reloadandinstalla longer one (they install and build), andchat40 s.--paneand--timeoutgo before the command. After the command, an option belongs to that command (notify --pane).--jsonprints one JSON value on standard output, also for an error. It counts anywhere in the arguments. The keys are sorted.- Without
--json, a command prints its text. A command with no text of its own prints its JSON fields asname: valuelines, sorted by name. - An error goes to standard error as
ovvi: ‹message›(Error codes). With--json, an error also prints{"error":{"code":"…","message":"…"}}on standard output, the script's own errors too (usage,no_ovvi,timeoutandno_herdr). - A
<pane>is a pane id of the asking session (w1:p3) or‹session›/‹pane id›of another session on the same machine (pi-herd/w1:p1). It is always on the asking pane's machine. A session that herdr does not run there, or a pane that is not in it, ispane_not_found. Text that is not a pane isbad_argument. - Exit codes: 0 done, 1 failed, 2 usage, 3 no Ovvi, 4 no answer, 5 not in herdr (Exit codes).
status
Whether Ovvi shows the asking pane, from herdr's tokens alone. It never asks Ovvi, so it answers fast, also when no Ovvi runs. It exits 0 when an Ovvi follows the session, 3 when none does, and 5 when herdr has no such pane (Is my pane in Ovvi?).
$ ovvi status
Shown in Ovvi
$ ovvi status --json
{"session":"default","pane":"w1:p2","open":true,"shown":true,"remote":false,"protocol":1}
$ ovvi status --watch # the JSON line again each time it changes; reads every 2 sThe text is "Shown in Ovvi", "Open in Ovvi, not shown" or "Not in Ovvi".
help
ovvi help # every command with its synopsis, status too
ovvi help open # the synopsis and the details of oneOvvi writes the help from its commands, so it lists what that Ovvi has.
ovvi with no command is ovvi help. With --json, help answers
{"commands":[{"name","usage","summary"}…]}, and help <command> answers
its name, usage, summary and help.
info
What the Ovvi that takes the request offers.
$ ovvi info --json
{"agents":["claude","codex","pi","opencode"],"app":"0.1","appearance":"dark",
"commands":["help","info","float","unfloat","copy","plugins","open","reveal","card","shown","notify","agent","chat","view","balance"],
"machine":"This Mac","protocol":1}| Field | Meaning |
|---|---|
app | Ovvi's version |
protocol | the protocol version it speaks |
machine | Ovvi's name for the asking pane's machine: This Mac, or the name in the machine list |
appearance | light or dark, as Ovvi draws now (for an HTML report that matches) |
commands | the commands this Ovvi parses (status is the script's) |
agents | the agents ovvi agent starts with the chat view, by command |
plugins
The kinds an agent can open: the browser and every plugin, from the app's plugin registry (Where the registry looks, The manifest). Terminal and agent panes are herdr's and not in the list.
ovvi plugins [--for <path or URL>]
ovvi plugins new <name>
ovvi plugins reload [<id>]
ovvi plugins install <source>$ ovvi plugins
browser Browser urls app
explorer File Explorer files,folders app
git Git git app
csv-table CSV Table files ~/.config/ovvi/plugins/csv-table
broken not loaded ~/.config/ovvi/plugins/broken package.json: ovvi.entry is missing
$ ovvi plugins --for data.csv # the plugins that open this file, folder or URL, the default first
csv-table CSV Table files ~/.config/ovvi/plugins/csv-table default
explorer File Explorer files,folders app
csv-chart CSV Chart files ~/.config/ovvi/plugins/csv-chart[
{ "id": "csv-table", "name": "CSV Table", "icon": "tablecells",
"description": "A CSV or TSV file as a table you can sort.", "version": "0.1.0",
"opens": { "files": ["*.csv", "*.tsv"] }, "chooser": null,
"source": "~/.config/ovvi/plugins/csv-table",
"log": "/Users/me/Library/Logs/Ovvi/plugins/csv-table.log" },
{ "id": "broken", "source": "~/.config/ovvi/plugins/broken",
"error": "package.json: ovvi.entry is missing" }
]id,name,icon,opensandchooserare the manifest's fields, as written:openshas only the keys the manifest gives (files,folders,git,urls,priority).descriptionandversionare the package's.sourceis the Plugins page's row text:appfor the browser and the default plugins (the row says "Comes with Ovvi"), thenpm:orgit:source, the local path, or the folder (The Plugins page).logis the full path of the plugin's log (Errors and logs). It shows only for a pane on This Mac, where the file is, and not for the browser.- A package that did not load shows only
id,source(its folder) anderror. In the text, its row saysnot loaded. - With
--for, the list holds only the plugins that open the argument, in the order of Which plugin opens a file, each with"default": trueorfalse. For a file, the plugins that open it only as a choice in Open With ("priority": "option") come after them:ovvi open <id> <file>opens the file with any of them. A relative path is read from the command's folder, and a folder is one on the disk of the pane's machine (a missing path counts by its name, or as a folder when it ends in/).openpicks its default plugin by the same order.
plugins new
$ ovvi --timeout 120 plugins new "CSV Table"
folder: /Users/me/.config/ovvi/plugins/csv-table
id: csv-table
log: /Users/me/Library/Logs/Ovvi/plugins/csv-table.logNew Plugin… with the name as an argument: Ovvi copies the template into the
plugins folder, installs its dependencies, builds it and confirms it, as the
Plugins page does (Start from the template).
It answers id, folder and log. A name with no letter or digit, or a
name that is taken, is failed with the page's words. The Plugins page shows
Creating… meanwhile.
plugins reload
$ ovvi plugins reload csv-table --json
{"built":true,"cards":1,"id":"csv-table","ms":840}
$ ovvi plugins reload # every plugin with an open card
csv-table built in 840 ms, 1 card(s)Reload Plugin for one id, or Reload Plugins with none (Reload). Ovvi reads the registry again first, so a folder made by hand loads.
- One id answers
id,built,ms(the time of the build and the reload) andcards(the plugin's cards in every window: a pane shown in two windows counts twice). No id answers{"plugins":[…]}, one entry per plugin. builtis false for a plugin that the user did not confirm yet: it builds nothing, and its cards still ask the user.- A card with unsaved edits stops it with
dirty, which names the pane. Ovvi never discards edits for an agent. It looks before anything builds and again before each plugin, and the message names the plugins reloaded before it. A card that gets edits during its plugin's build keeps its page while the plugin's other cards load the new build, and the answer isdirtytoo. - A failed build is
build_failed. Its message is the card's toast title and the build's last 20 lines. - An unknown id is
plugin_not_found, with the reason when its manifest did not load. The browser isbad_argument: it has nothing to build. A plugin that the user did not confirm yet isfailed: its card asks the user first.
plugins install
$ ovvi --timeout 300 plugins install npm:ovvi-csv-table
csv-table CSV Table files npm:ovvi-csv-tableInstall from the Plugins page, for an agent
(Install). The source is
npm:<package>, git:<repository> or a folder (a relative one from the
command's folder).
- Ovvi shows the confirmation on the window of the asking session, with "‹agent› in pane ‹pane› asks to install it." first (The confirmation). Ovvi does not come to the front: it asks for attention in the Dock. Return does not press Install, so keys typed elsewhere cannot install it.
- It answers after the install and the build, with the plugin's entry as in
the list. Cancel is
declined. With no window of the session left to show the confirmation, the install isfailed. - A source that is not one, or one already listed, is
bad_argument. Update stays on the Plugins page, where the user chooses it. - If the command's
--timeoutends first, the confirmation stays, and the install goes on after the user's answer. - The Plugins page shows Installing… and any failure, as for its own install.
new, reload and install from a pane on a remote machine are
other_machine: the plugins are on the Mac.
open
Makes a browser or plugin card: a herdr pane with the label
ovvi:<kind>#<iid> <arg> and its placeholder, as the app's own commands do
(Labels and instance ids).
ovvi open <kind> [<arg>...] [<place>] [--new]
ovvi open <path-or-url> [<place>] [--new]
<place>: --beside <pane> [--down] a split of that pane, right (or below). The default: beside the asking pane
--down below the asking pane
--tab a new tab right after the asking pane's tab, not selected
--float as --tab, then floats it
--replace <pane> that browser or plugin card shows the new kind and argumentovvi open git . uncommitted # the Uncommitted diff of this repository, beside me
ovvi open git . 4f2a91c --tab # a commit, in a new tab
ovvi open browser http://localhost:3000 --down # a browser card below me
ovvi open ./docs/report.md # the default plugin: File Explorer, its Markdown page
ovvi open https://github.com/o/r/pull/7 # a plugin for that URL, else the browser
ovvi open browser ./report.html # a file in the browser, This Mac only
ovvi open browser http://localhost:3000/settings --replace w1:p7 # navigate, Back returns
ovvi open explorer . --float # a file tree in a floating window{"arg":"/repo/ uncommitted","kind":"git","label":"ovvi:git#k3x9q2 /repo/ uncommitted",
"pane":"w1:p7","reused":false,"shown":true}- Kind or path. A first word that is a kind's id (from
ovvi plugins) is the kind. A path with the same name as a kind is written./name. A word that is not a kind, with more words after it, isplugin_not_found. - A path or URL alone opens with its default plugin, by the order of
ovvi plugins --for. AnhttporhttpsURL opens with a plugin whoseurlsmatch it, else in the browser. Afile://URL opens in the browser. A path must exist on the asking machine. A missing one isbad_argument. Typed in a shell (a pane with no agent), it also makes the windows that show the session say "File not found: ‹path›" (Opening links and files). A file that no plugin opens isbad_argumenttoo. - Paths are read from the command's folder, and the card gets the absolute path.
- The argument follows the kind's
opens: a URL, a file's path or a folder. Git takes a folder of a repository (the card gets its root) and thenuncommittedor a commit: its hash, short or whole, or a ref (main,HEAD~2). The card gets the commit's whole hash; one that names no commit isbad_argument. File Explorer takes a folder as Open File Explorer Here does: the git root when there is one.browser ./page.htmlis afile://URL, and a path's?queryand#fragmentstay the URL's (browser "./page.html?v=3"). A file whose name has?or#opens as afile://URL with them encoded (%3F,%23). Afile://URL, for the browser or any plugin, opens only for a pane on This Mac. A missing or wrong argument, or one that makes no label (a control character, over 1024 characters), isbad_argument. - Reuse. Without
--new, a card of the same kind and argument already in the session where the card would open is the answer ("reused": true), and nothing new opens. When it is not in view, Ovvi shows the notice "‹agent› points to ‹title›" with Show. A reused card stays where it is, also with--float:ovvi floatfloats it. - The view. A split of the asking pane shows where the agent is. A new tab is not selected. A new card out of view gets the notice "‹agent› opened ‹Kind› ‹title›" with Show, in each window that shows the session (Notice). A float opens its window without activating Ovvi.
- One place at most. Two places are
usage. - Replace takes only a browser or plugin card. Ovvi renames its label
with the card's own iid, so the card follows it as a label from elsewhere:
a browser card as a new history entry, a plugin card with Back. A new kind
makes the card again. A terminal or agent pane is
bad_argument, so an agent never ends a shell through Ovvi. A card with unsaved edits isdirty.--replacewith--newisusage. - A failed float of
--floatcloses the new pane. shownsays whether the card is in view in a window of this Ovvi. Ovvi answers once a snapshot has the new pane in its place.
‹agent› is the asking pane's agent name ("Claude Code"), else its program ("zsh"), else its card's title. ‹title› is the card's title as its window shows it (a browser card's page title).
float and unfloat
ovvi float w1:p3 # over the window where the user last worked in this session
ovvi float pi-herd/w1:p1 --on-top
ovvi unfloat w1:p3 # Detach: back into its tab{"existing":false,"floating":true,"onTop":false,"pane":"w1:p3"}- The float goes over the window of this Ovvi where the user last typed or
clicked in a card of the asking session
(Floating pane). A pane of a session with
no page gets a float-only page. A session that no window shows is
failed. A float that fails says so only in the answer: the window shows no warning. - The window comes to the front without activating Ovvi, and the keyboard stays in the window that had it.
- A pane that floats already comes to the front (
"existing": true).--on-topturns on Stay on Top. unfloatanswers{"floating":false,"pane":"w1:p3"}. Of a pane that does not float, it isnot_floating.
reveal
Points the user at a pane, without moving their view.
$ ovvi reveal w1:p3 --json
{"pane":"w1:p3","shown":false}- In view: the card's border flashes in the accent color once (0.6 s). A floating card flashes in its own window.
- Not in view: a notice in the information tone, "‹agent› points to ‹title›", with Show, in each window that shows the session. Show selects the pane's space, tab and card in that window.
- A pane of a session that Ovvi does not show is
failed.
card
What a card shows.
$ ovvi card w1:p3 --json
{"arg":"/repo/Sources/main.swift","dirty":true,"floating":false,"kind":"explorer",
"pane":"w1:p3","shown":true,"title":"main.swift"}kindis a browser or plugin id,terminaloragent. An agent card adds"agent"(for example"claude"). An agent that has the chat view also has"view":"chat"or"tui", the card's Chat | TUI switch.argis the label's argument. For a terminal or an agent, it is the folder.titleis the card's title (How it shows),dirtyis the unsaved-edits dot. A plugin's own state is not in the answer.errorcomes only when there is one: why a plugin is missing or did not load, or a plugin page's failure.
shown
The panes of the asking session that this Ovvi shows now.
$ ovvi shown --json
{"floating":["w3:p1"],"shown":["w1:p2","w1:p3"],"windows":1}shown lists the panes in a tab in view, floating the panes in windows of
their own, and windows counts the visible windows with the session's page
in view.
notify
A notification on the Mac, through the app's notifications (Notifications).
$ ovvi notify "Tests pass" "412 passed in 38 s"
notified: true
pane: w1:p2
$ ovvi notify "Review ready" --pane w2:p1- The title is the agent's title, the body its body, and the subtitle
"‹agent› · ‹session›" (on a remote machine "‹agent› · ‹machine›", or
"‹agent› · ‹machine› · ‹session›" for a session other than
default). - A click brings Ovvi to the front and shows the pane: the asking pane, or
--pane's. Showing it there is the user's own action. - One per asking pane every 10 s, whatever
--panenames: a second israte_limited. A newer one from the same pane replaces the older in Notification Center. - With Settings ▸ Agents ▸ When an agent sends one off, or while Show Only
Pinned Spaces hides the pane's space, it posts nothing and answers
"notified": falsewith exit 0. That does not count toward the limit. - If macOS does not let Ovvi post notifications, it is
failedwith macOS's words.
agent, chat and view
$ ovvi agent claude --json # beside me, with the chat view
{"agent":"claude","pane":"w1:p9"}
$ ovvi agent pi --tab
$ ovvi --timeout 40 chat w1:p4 # Relaunch with Chat, for an idle agent
$ ovvi view w1:p4 tui # the card's Chat | TUI switchagent <name>takesclaude,codex,pioropencode. Another name isbad_argument. It makes a shell pane at the place, asopendoes (the same<place>options, but not--replace, which isusage), shows the agent loader on it and types the agent's command line with the chat view's launch flags, as Start Agent Here does (New pane). It answerspaneandagentwhen the line is typed, not when the agent is ready. A pane that closes first isfailed. The card opens on the chat, as after Relaunch with Chat.chat <pane>runs Relaunch with Chat and answerspaneandagentwhen the new line is typed, in about 3 s. It never asks the user, so it refuses instead, in this order:- The asking pane itself is
self. Its agent would end before the answer. - A pane with no chat agent, one that shows the chat already, or one whose
agent session herdr does not know is
bad_argument. - A relaunch that already runs, or an agent that works or needs you, is
busy.
- The asking pane itself is
- An agent that does not quit fails only after Relaunch with Chat's 30 s
wait, so pass
--timeout 40tochat. With the default, the script exits 4 first. chatclears a draft that the user types into the agent (⌃C, then the quit command), and the loader takes the keys until the agent is back.view <pane> chat|tuiflips the card's Chat | TUI switch in every window of the session, as a click on it does, and answerspaneandview. It changes only what the card draws. A pane whose agent has no chat view isbad_argument; another word thanchatortuiisusage.
balance
Evens out the splits of a pane's tab, as Balance Splits does: each run of panes side by side or stacked gets equal shares. Without a pane, it is the asking pane's tab.
$ ovvi balance
pane: w1:p2
tab: w1:t1
$ ovvi balance w2:p1 --json
{"pane":"w2:p1","tab":"w2:t3"}copy
Puts its standard input on the Mac's clipboard (at most 1 MiB, else
bad_argument). It prints nothing and answers {"bytes": n} with --json.
It never reads the clipboard.
git diff | ovvi copyFrom a program
A program runs the script like any command:
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
const OVVI = join(homedir(), ".cache", "ovvi", "bin", "ovvi");
/** Runs `ovvi <args> --json`; code 5 also when the script is missing. */
export const ovvi = (args: string[]) =>
new Promise<{ code: number; json: any; err: string }>((ok) =>
execFile(OVVI, [...args, "--json"], { timeout: 15_000 }, (e, out, err) =>
ok({
code: e ? (typeof e.code === "number" ? e.code : 5) : 0,
json: out ? JSON.parse(out) : undefined,
err,
}),
),
);The script
The whole client, as Ovvi carries it (Ovvi/Agent/ovvi.sh). Line 2 is
its version, # ovvi-cli ‹protocol›.‹revision›
(Versions).
#!/bin/sh
# ovvi-cli 1.2
# Written by Ovvi: asks the Ovvi that follows this herdr session. Ovvi parses the arguments.
# https://ovvi.dev/docs/agent-control/cli
herdr=${HERDR_BIN_PATH:-herdr} session=${HERDR_SESSION:-default} pane=${HERDR_PANE_ID:-} timeout=10 json= j=
dir=$HOME/.cache/ovvi/rpc
for a; do [ "$a" = --json ] && json=1; done
# Exits with $1, saying $3 on stderr and, with --json, {"error":…} with the code $2 on stdout.
fail() {
echo "ovvi: $3" >&2
[ -z "$json" ] || printf '{"error":{"code":"%s","message":"%s"}}\n' "$2" \
"$(printf %s "$3" | tr -d '\000-\037' | sed 's/[\\"]/\\&/g')"
exit "$1"
}
while :; do
case ${1:-} in
--pane) [ $# -ge 2 ] || fail 2 usage "--pane needs a pane"; pane=$2 && shift 2 ;;
--timeout) [ $# -ge 2 ] || fail 2 usage "--timeout needs whole seconds"; timeout=$2 && shift 2 ;;
--json) j=--json && shift ;;
*) break ;;
esac
done
case $timeout in '' | *[!0-9]*) fail 2 usage "--timeout takes whole seconds" ;; esac
# A --json before the command goes after it, so $1 is the command.
set -- "$@" $j
[ -n "$pane" ] || fail 5 no_herdr "run me in a herdr pane, or pass --pane"
h() { "$herdr" --session "$session" "$@" 2>/dev/null; }
# status: herdr's tokens only, so it answers with no Ovvi.
token() { printf %s "$1" | sed -n "s/.*\"$2\":\"\\([^\"]*\\)\".*/\\1/p"; }
status() {
p=$(h pane get "$pane") || fail 5 no_herdr "herdr has no pane $pane in session $session; pass --pane"
w=$(h workspace get "$(token "$p" workspace_id)")
shown=$(token "$p" ovvi-shown) open=$(token "$w" ovvi-open)
printf '{"session":"%s","pane":"%s","open":%s,"shown":%s,"remote":%s,"protocol":%s}\n' "$session" "$pane" \
"$([ -n "$open" ] && echo true || echo false)" "$([ -n "$shown" ] && echo true || echo false)" \
"$([ "$shown" = remote ] && echo true || echo false)" "${open:-null}"
[ -n "$open" ] || return 3
}
if [ "${1:-}" = status ]; then
case ${2:-} in
--json) status ;;
--watch) last= && while s=$(status); c=$?; [ $c -le 3 ]; do
[ "$s" = "$last" ] || echo "$s"; last=$s; sleep 2; done ;;
*) s=$(status); c=$?; case $s in
*'"shown":true'*) echo "Shown in Ovvi" ;; *'"open":true'*) echo "Open in Ovvi, not shown" ;;
?*) echo "Not in Ovvi" ;; esac; exit $c ;;
esac
exit
fi
umask 077 && mkdir -p "$dir" || fail 5 no_herdr "cannot write $dir"
id=$( (cat /proc/sys/kernel/random/uuid || uuidgen) 2>/dev/null | tr A-F a-f) f=$dir/$id
[ -n "$id" ] || fail 5 no_herdr "no uuidgen"
# One byte over the 1 MiB limit, so Ovvi sees standard input that is too large.
[ "${1:-}" = copy ] && head -c 1048577 >"$f.in"
printf '%s\000' ovvi-req 1 "$PWD" "$@" >"$f.tmp" && mv "$f.tmp" "$f.req" || fail 5 no_herdr "cannot write $dir"
h pane report-metadata "$pane" --source ovvi-cli --token "ovvi-req=$id" --ttl-ms 3000 >/dev/null ||
{ rm -f "$f".* && fail 5 no_herdr "herdr refused the request on pane $pane; pass --pane"; }
# Waits until $1 holds, for $2 seconds by the clock (1 s less at worst).
until_() { end=$(($(date +%s) + $2)) && until eval "$1"; do [ "$(date +%s)" -lt $end ] || return 1; sleep 0.05; done; }
if ! until_ '[ ! -e "$f.req" ]' 3 && mv "$f.req" "$f.dead" 2>/dev/null; then
rm -f "$f".* && fail 3 no_ovvi "no Ovvi shows herdr session $session"
fi
until_ '[ -e "$f.exit" ]' "$timeout" ||
{ rm -f "$f".* && fail 4 timeout "Ovvi did not answer within $timeout s"; }
cat "$f.out" 2>/dev/null
code=$(head -n 1 "$f.exit") && tail -n +2 "$f.exit" >&2
rm -f "$f".*
exit "${code:-1}"