A herdr plugin that renders a Pairing Code
QR so the Heeler app can add this machine as a Host by scanning it
(ADR 0007). The pair action opens a popup pane: confirm which of the
machine's addresses go into the code, then scan the QR with the app.
The Pairing Code carries a single-use Bootstrap Key: the app connects with
it once, submits its Device Key public line, and the plugin's Enrollment
entrypoint appends that key to authorized_keys automatically. See
Bootstrap Key lifecycle.
The plugin also delivers Agent Notifications (ADR 0008) and per-Host
Live Activity updates: two independent [[events]] entries on
pane.agent_status_changed. herdr runs every matching hook for the same
event (verified on 0.8.0). The notify hook pushes encrypted Blocked/Done
alerts; the activity hook pushes the Host's eligible-agent snapshot to any
device that has registered a Live Activity token. See
Notify hook and Activity hook.
Starting with plugin 0.4.0, a startup hook also exports the Host's herdr sidebar layout for Heeler. Both event hooks refresh it before their notification gates. See Sidebar layout snapshot (v1).
The plugin also collects Claude Code and Codex Usage on the Host for the app's Usage Limits and Token Usage views.
- Node.js >= 20 on
PATH - herdr >= 0.7.5
- An OpenSSH server on this machine (the code pins its host key fingerprint)
On a new machine, first install the requirements above and make sure the
OpenSSH server is running. On macOS, enable System Settings > General >
Sharing > Remote Login. If /etc/ssh still has no ssh_host_*_key.pub
files, generate them:
sudo ssh-keygen -AConfirm the command-line requirements:
herdr --version # 0.7.5 or newer
node --version # 20 or newerInstall the plugin from GitHub:
herdr plugin install ZingerLittleBee/Heeler --ref plugin --yesThe plugin branch holds only the files the plugin runs, published from
plugin/ on main, so the install skips the rest of the repository.
Herdr stores the plugin in its managed checkout and runs the manifest's
npm ci build command automatically. Verify that the plugin is installed and
enabled:
herdr plugin list --plugin heeler
herdr plugin config-dir heelerOpen the Pairing Code popup:
herdr plugin action invoke heeler.pairThe QR, and any reason pairing cannot start, appear in a Herdr popup window, not in the terminal that ran invoke.
Scan the code in Heeler to add this machine as a Host, or press c on the
QR screen to copy the Pairing Code and paste it in the app (macOS uses
pbcopy; elsewhere the code is printed for manual selection). Then open the
Heeler settings, enable Agent Notifications, grant the iOS notification
permission, and enable Notifications for this Host. Leave Custom Push
Relay empty to use the production endpoint at
https://heeler-apns.bybee.dev.
To update an installed GitHub-managed plugin, run the same plugin install
command again. Plugin 0.6.0 scopes notifications to the herdr session they
come from; update it on every Host along with the app (see
Updating to 0.6.0). The app's Host details show the
installed version and offer this command when it is older than the plugin
the app was built alongside, and can run it on the Host over SSH (ADR 0027).
The app first looks for Node.js 20 or newer for the npm ci build where
version managers (nvm, fnm, Volta, asdf, mise and others), Homebrew, Nix and
system packages keep their default, then on the login shell's PATH, and
reports the Host's Node.js as missing or too old instead of installing.
On herdr 0.9.4, reinstalling a disabled plugin keeps it disabled, where herdr 0.9.3 turned it back on. Turn it on afterwards:
herdr plugin enable heelerUnlike plugin install, plugin enable only works through a running herdr
session: if the default session is not running, name one that is, for
example herdr --session work plugin enable heeler. For a disabled plugin,
the app's Host details show the enable command for the Host's session.
To inspect notification or pairing failures:
herdr plugin log list --plugin heeler --limit 20For local development, link the working tree instead. Build it first because
plugin link does not run build commands:
cd plugin && npm ci
herdr plugin link "$(pwd)"
herdr plugin action invoke heeler.pairThe popup checklist: arrows or j/k move, space toggles, a toggles all,
enter mints a Bootstrap Key and renders the QR, q/escape closes (revoking
the key). On the QR screen, c copies the Pairing Code; any other key
closes. When the code expires, enter generates a fresh one. Once a device
enrolls, the QR is replaced by a success screen showing the enrolled Device
Key's fingerprint and label; press r there to revoke that key (removing its
authorized_keys line), or any other key to close.
Starting with plugin 0.5.0, the advertised SSH port is configurable. It is 22
by default; override it with pair.json in the plugin config directory when
OpenSSH listens elsewhere — for example, Tailscale SSH on port 22 with
OpenSSH on 2222:
herdr plugin config-dir heeler
# write {"ssh_port": 2222} into that directory as pair.json
herdr plugin action invoke heeler.pair| Field | Type | Meaning |
|---|---|---|
ssh_port |
integer | SSH port advertised in the Pairing Code, 1..65535. Default 22. An absent pair.json means 22; a file that cannot be read or parsed, or an ssh_port outside that range, also falls back to 22 and says so in the checklist. |
addresses |
string[] | Addresses (IPs or host names) the phone reaches this machine on but its interfaces do not show: a container behind a published port, a NAT, a DNS name. Listed first in the checklist and pre-checked; the enumerated addresses follow unchecked. Default none. A value that is not a list of non-empty strings without whitespace is ignored and says so in the checklist. |
launcher |
string | Absolute path of a launcher to put in a Pairing Code v2 instead of the generated one. Used only when this herdr runs outside ~/.config/herdr. A path with ', \ or control characters is ignored and says so in the checklist. Read once when the popup opens. |
addresses (plugin 0.6.2 and later; older plugins ignore it) is what a
machine behind a port mapping needs, for example
herdr in a Docker container whose sshd is published as port 2222 on a NAS
at 192.168.1.10. Inside the container the plugin only sees the bridge
address (172.17.x.x), which the phone cannot reach:
{"ssh_port": 2222, "addresses": ["192.168.1.10", "nas.example.com"]}The Pairing Code pins the host key found under /etc/ssh (src/host-key.js),
so the alternate port must be served by the same sshd — another Port
line in sshd_config, not a second instance with its own HostKey, whose
fingerprint the code would not match. pair.json is re-read on every
checklist repaint, so an edit made in another pane lands on the next keypress
without reopening the popup. addresses builds the checklist, so it is read
once when the popup opens.
The checklist and QR screen both show the advertised port, and the checklist
names an override it could not honor rather than quietly handing back 22 —
the port a Tailscale-SSH Host is trying to get away from. Pairing still
requires a real OpenSSH listener (host key under /etc/ssh,
authorized_keys forced commands); Tailscale SSH alone cannot run the
ceremony.
On a machine holding an address that might be a tailnet one, the popup asks
Tailscale once, after the first paint, whether it is serving SSH:
tailscale debug prefs (RunSSH), falling back to tailscale status --json
(this node's sshHostKeys). If it is, and one of the addresses
status --json reports as this node's own (Self.TailscaleIPs) is checked
while the code still advertises port 22, the checklist says so before the QR
appears — tailscaled would answer that port, and Enrollment cannot run there.
The warning matches those addresses exactly rather than the 100.64.0.0/10
range, which is the whole carrier-grade NAT block: measured on a VPS whose
own NIC held 100.114.1.129 while tailscale0 held 100.73.39.6, a
range test warns about an address tailscaled never answers for. The range is
only a screen for whether asking Tailscale is worth a subprocess at all.
Measured against Tailscale 1.102.4: RunSSH tracks tailscale set --ssh
in both directions, sshHostKeys was absent from Self and from every peer
either way, and Self.CapMap is no substitute — its cap/ssh and
ssh-behavior-v1 entries are ACL grants that survive --ssh=false.
Every probe only ever answers yes. No tailscale on PATH (nor in the macOS
app bundle), a logged-out tailscaled, an output shape that changed, or a
readable RunSSH with unreadable addresses all read as "not serving" and
warn about nothing: a false alarm on every pairing would cost more than this
warning saves.
A missed warning is caught on the phone instead. tailscaled identifies itself
as SSH-2.0-Tailscale, and the app skips any candidate that does before
comparing its host key or authenticating, then names Tailscale SSH and
ssh_port rather than reporting the Host unreachable. Stopping before
authentication matters: under a check policy tailscaled holds
authentication open for a browser login, and under accept it would run the
requested command as the user.
The Pairing Code is a single-line string; the QR image is just its rendering.
HERDR-PAIR:<version>:<base64url(JSON, no padding)>
HERDR-PAIRis a literal prefix; anything else is rejected (bad_prefix).<version>is a decimal integer. This document specifies versions1and2(Pairing Code v2); any other value is rejected (unsupported_version).- The body is the payload JSON, UTF-8, encoded as unpadded base64url
(RFC 4648
-/_alphabet). Invalid base64url or JSON is rejected (bad_encoding).
| Wire key | Type | Required | Meaning |
|---|---|---|---|
addrs |
string[] | yes | Candidate addresses in the order the app should try them. Non-empty; each entry a non-empty string without whitespace. IPv6 literals carry no brackets and no zone id. |
port |
integer | yes | SSH port, 1..65535. |
user |
string | yes | SSH username. Non-empty, no whitespace. |
fp |
string | yes | Host key fingerprint exactly as OpenSSH prints it: SHA256: + 43 chars of unpadded standard base64. The app pins this instead of showing a TOFU prompt. |
seed |
string | no | Raw 32-byte Ed25519 seed of the Bootstrap Key, unpadded base64url. Present together with exp or not at all. |
exp |
integer | no | Unix-seconds expiry of the Bootstrap Key. Present together with seed or not at all. |
A payload violating these rules is rejected (bad_payload). A code without
seed/exp is a config-only Pairing Code: same ceremony minus the bootstrap
connection.
Encoders emit the keys in the order of the table above with no JSON whitespace, so a given payload has exactly one canonical code. Decoders do not depend on key order.
- Decoders ignore unknown payload fields; additive metadata may appear within v1.
- Any breaking change (removing, renaming, or re-typing a field, or changing
the envelope framing) bumps
<version>, and both implementations must be updated together.
Version 2 pairs a Host whose herdr lives outside the user's own config home,
such as a desktop app that bundles herdr and runs it with its own
XDG_CONFIG_HOME (ADR 0023). It keeps the v1 envelope, error codes, and
compatibility rules, and adds two required fields that name that herdr: its
API socket and a launcher for it.
| Wire key | Type | Required | Meaning |
|---|---|---|---|
addrs |
string[] | yes | As v1. |
port |
integer | yes | As v1. |
user |
string | yes | As v1. |
fp |
string | yes | As v1. |
sock |
string | yes | Absolute path of the herdr API socket. |
herdr |
string | yes | Absolute path of the launcher, an executable that behaves as herdr for this socket. |
seed |
string | no | As v1. |
exp |
integer | no | As v1. |
Decoders check the v1 fields first, then these rules; a violation is rejected
(bad_payload):
sockandherdrare absolute paths without',\, or ASCII control characters (U+0000 to U+001F, U+007F). Spaces are allowed.sockuses herdr's socket layout: it ends in/herdr/herdr.sock(the default session) or/herdr/sessions/<name>/herdr.sockwith a valid session name (herdr sessions), so the hooks can tell which session they serve.sockis at most 96 bytes of UTF-8. Attach and wake use herdr's client socket, named by inserting-clientbefore.sock, and that path must still fit macOSsun_path(103 bytes plus the NUL).
Encoders emit the keys in the order of the table above with no JSON whitespace, as in v1.
Adding fields is allowed within v1, but v1 decoders ignore unknown fields. An
older app would drop sock and herdr and pair against the default socket and
the herdr on PATH, without any error. Under version 2 it refuses the code
(unsupported_version) and saves nothing.
Only the app decodes v2. This plugin decodes v1 and rejects v2 as
unsupported_version; it emits v2 only for a herdr outside ~/.config/herdr
(below) and v1 otherwise. A v1 code
that carries sock or herdr still pairs without an endpoint. Endpoints work on macOS and Linux Hosts only; a native
Windows Host paired from a v2 code fails with an explicit error.
For every herdr command on the Host, the app runs the launcher, never a
herdr from PATH, as a positional argument of a POSIX shell body that
exports HERDR_SOCKET_PATH as sock:
LC_ALL=C /bin/sh -c 'export HERDR_SOCKET_PATH="$2"; exec "$1" session list --json' herdr '<herdr>' '<sock>'The launcher must:
execthe real herdr with the arguments it was given and write nothing of its own to stdout or stderr.session list --jsonandplugin list --jsonoutput is parsed as one JSON document, and attach runs on a PTY whose output goes straight to the terminal.- Run herdr, and the server behind
sock, with the endpoint's ownXDG_CONFIG_HOMEandXDG_STATE_HOME, soplugin config-dirandsession listresolve this instance and neithernotifications.jsonnor the plugin's state is shared with the user's own herdr. A sharednotifications.jsonwould let the endpoint Host's Notification Registration evict the user's existing Host for the same machine.HERDR_PLUGIN_STATE_DIRdepends only onXDG_STATE_HOME, and with a shared one the two default sessions overwrite each other's notification markers and Live Activity state. - Give herdr a
PATHon which this plugin'snodeand the user's agents resolve. The app sets noPATH, so a server that the launcher starts otherwise inherits sshd's minimalPATH, and the hooks it runs cannot findnode. - Let
remote-client-bridgestart or reach the endpoint's server. The app runs it to wake herdr only when the socket file exists but refuses connections, and for asessions/<name>socket it also exportsHERDR_SESSION, as for a regular named session.
An exit status of 126 or 127 from the launcher's session list, plugin list
or attach is reported as a missing launcher, not as herdr missing from PATH.
A failed wake keeps the original socket error.
A herdr run with its own XDG_CONFIG_HOME, such as a per-project instance or
a profile started by a wrapper script, keeps its socket and this plugin's
config dir apart from ~/.config/herdr/plugins/config/<id>. The pairing popup
detects that from HERDR_PLUGIN_CONFIG_DIR and emits a Pairing Code v2 whose sock is its
HERDR_SOCKET_PATH (ADR 0029):
- The herdr must also run with its own
XDG_STATE_HOME, so itsHERDR_PLUGIN_STATE_DIRis not~/.local/state/herdr/plugins/<id>(see the launcher contract above). - Unless
pair.jsonnames alauncher, the popup writes one tolauncherin the plugin config dir (mode 0700) each time it opens. It refuses a config dir other users can write. It exports the popup'sPATH, itsXDG_CONFIG_HOME,XDG_STATE_HOME,XDG_DATA_HOMEandXDG_CACHE_HOMEwhen set, and itsHERDR_*variables except the ones herdr sets per process and an inherited client socket (HERDR_SOCKET_PATH,HERDR_CLIENT_SOCKET_PATH,HERDR_BIN_PATH,HERDR_ENV,HERDR_SESSION,HERDR_PLUGIN_*,HERDR_WORKSPACE_ID,HERDR_TAB_ID,HERDR_PANE*), thenexecsHERDR_BIN_PATH. The popup runs inside the server, so these are the server's own settings. - Before the launcher goes in the code, the popup runs
plugin config-dir <plugin id>through it the way the app does, from a minimal environment, and requires this plugin's config dir back with nothing on stderr within 5 seconds. A generated launcher is tested under a staging name and replaces the previous one only when it passes. - The checklist shows
Endpoint <sock> via <launcher>. When the state home is shared, the socket or launcher breaks a v2 rule, no launcher can be written, or the test run fails, the popup says why and offers no code: a v1 code would pair the phone with the user's own herdr instead.
A herdr that shares the user's config home but listens on another socket gets
a warning instead: an endpoint there would share notifications.json with the
user's own herdr. The phone keeps the launcher's path, so after moving herdr
or changing the environment it starts with, opening the popup once rewrites
the launcher in place.
Confirming the address checklist mints an ephemeral Ed25519 Bootstrap Key.
Its raw 32-byte seed rides in the Pairing Code (seed/exp); its public half
is written to ~/.ssh/authorized_keys as a restricted line:
restrict,command="'<node>' '<plugin>/src/pair-accept.js' --state-dir '<state>' --pairing-id <id>" ssh-ed25519 <blob> herdr-pairing:<id>:exp:<unix-seconds>
restrictplus the forced command mean the key can do exactly one thing: run the Enrollment accept entrypoint. The command embeds absolute paths because sshd provides no plugin environment.- The trailing
herdr-pairing:<id>:exp:<unix-seconds>comment marks the line so cleanup and the sweep can find it with no state beyond the file. - All edits to
authorized_keysare atomic (exclusive lock, temp file + rename) and preserve the file mode, so sshdStrictModeskeeps accepting the file.
The line is removed on successful Enrollment (self-revoke), when the 2-minute
TTL expires, when the popup exits (including SIGTERM/SIGHUP), and by a sweep
of expired lines every time the popup starts — so a killed popup leaves at
worst a line that dies with its TTL and is swept at the next start. Pending
ceremony state lives under $HERDR_PLUGIN_STATE_DIR/pending/<id>.json, never
in the plugin checkout. On successful Enrollment the accept entrypoint writes
$HERDR_PLUGIN_STATE_DIR/enrolled/<id>.json (the enrolled fingerprint and
line); the popup polls for it to show the success screen and drive the revoke,
and clears it on exit. An invalid submission does not consume the line; the
app may retry until the TTL runs out.
The app connects with the Bootstrap Key and writes its Device Key public line
(ssh-ed25519 <blob> [comment], printable-ASCII comment) followed by a
newline to stdin. The forced command answers with one line on stdout:
| Response | Meaning |
|---|---|
HERDR-ENROLL:OK:<SHA256:fingerprint> |
Device Key appended (or already authorized); bootstrap line revoked. Exit 0. |
HERDR-ENROLL:ERR:invalid_key |
Submission is not a bare Ed25519 public line. Line not consumed; retry allowed. Exit 1. |
HERDR-ENROLL:ERR:no_input |
Nothing arrived on stdin. Line not consumed. Exit 1. |
HERDR-ENROLL:ERR:expired |
TTL passed; the bootstrap line was removed. Regenerate the code. Exit 1. |
HERDR-ENROLL:ERR:unknown_pairing |
No pending ceremony (already used, cleaned up, or never existed). Exit 1. |
Human-readable detail goes to stderr. Manual demo from another machine:
# On the phone side stand-in: seed.key holds the Bootstrap Key in any format
# ssh accepts; the app itself uses the raw seed from the Pairing Code.
ssh -i seed.key <user>@<host> < device_key.pubtest-vectors/pairing-code-v1.json is the single source of truth for the
envelope, consumed by the Node tests here and the Swift tests in the app.
Valid vectors must decode to the given payload and (unless decodeOnly)
re-encode to the exact code; invalid vectors must fail with the given error
code (bad_prefix, unsupported_version, bad_encoding, bad_payload —
these map to the "parse" step of the pairing failure taxonomy).
test-vectors/pairing-code-v2.json has the same shape plus each endpoint's
socketPath and herdrPath. The Swift tests decode it; the Node tests encode
its canonical codes and reject its invalid ones.
The encrypted Agent Notification payload (ADR 0008): the notify hook encrypts it on the Host, the Push Relay and APNs carry it opaquely, and the app's service extension decrypts it. One compact JSON object:
{"v": 1, "kid": "<key id>", "n": "<nonce>", "ct": "<ciphertext>"}| Wire key | Type | Meaning |
|---|---|---|
v |
integer | Envelope version. This document specifies version 1; any other integer is rejected (unsupported_version). |
kid |
string | Key id: the first 8 bytes of SHA-256 over the raw 32-byte Notification Key, unpadded base64url. Derived on both ends, never stored. The app uses it to select the right Notification Key (and thus Host) when several Hosts are registered. |
n |
string | 12-byte AES-GCM nonce, unpadded base64url. Freshly random per envelope. |
ct |
string | AES-256-GCM ciphertext followed by the 16-byte tag, unpadded base64url. |
A missing or mistyped field, invalid base64url, or wrong nonce/tag length is
rejected (bad_envelope).
AES-256-GCM under the per-host Notification Key, with the UTF-8 bytes of
HERDR-NOTIFY:1 as additional authenticated data so a ciphertext re-framed
under another version fails authentication. Tampered material or the wrong
key is rejected (decrypt_failed) — GCM cannot tell those apart. The
plaintext is compact JSON:
| Wire key | Type | Meaning |
|---|---|---|
pane |
string | herdr pane id the Agent lives in. Non-empty. |
kind |
string | Agent kind as herdr reports it (claude, codex, ...). Non-empty. |
status |
string | The new Agent Status. An open set: decoders pass unrecognized values through. Non-empty. |
ts |
integer | Unix-seconds of the status transition. Positive. |
project |
string | Optional, display only: the workspace label the Agent runs in — the project name the app's alert leads with. Omitted when the Host cannot resolve it. At most 256 characters (the hook trims to 80 before encrypting). |
title |
string | Optional, display only: the Agent's terminal title with status glyphs stripped — what it is working on. Same absence and length rules as project. |
A plaintext that is not JSON or violates these rules is rejected
(bad_payload). Every rejection is a typed error on the app side; the
service extension answers any of them with a generic fallback banner, never
a crash.
Encoders emit both JSON objects with the keys in table order and no
whitespace, so given inputs yield exactly one envelope; the shared vectors
assert on the exact string. An optional field with no value is omitted
entirely rather than sent empty, so absent, null, and "" all encode
identically. Decoders do not depend on key order and ignore unknown fields at
either layer (additive v1 metadata). Any breaking change bumps v, and both
implementations must be updated together.
test-vectors/notification-payload-v1.json is the single source of truth,
consumed by the Node tests here (encrypt direction: non-decodeOnly valid
vectors must be reproduced byte-for-byte) and the Swift tests in the app
(decrypt direction: valid vectors must yield the payload, invalid vectors —
including tampered-ciphertext and wrong-key cases — must fail with the given
error code: bad_envelope, unsupported_version, decrypt_failed,
bad_payload).
Where a Host learns which devices to notify: the app writes this file over
SSH during Notification Registration; the notify hook reads it and POSTs one
push per device entry. It lives at notifications.json inside this plugin's
config directory (the app resolves that directory via
herdr plugin config-dir). Writers replace the whole file atomically
(temp file + rename). The app keeps at most one entry per device token and
herdr session; removing an entry revokes that device for that session.
{
"v": 1,
"devices": [
{
"token": "a1b2c3...",
"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8",
"env": "production",
"session": "",
"notify": { "blocked": true, "done": true },
"live_activity": { "token": "c4d5e6...", "started_at": "2026-01-01T00:00:00Z" }
}
]
}| Field | Type | Meaning |
|---|---|---|
v |
integer | File format version. This document specifies version 1; a reader finding any other value must treat the file as absent (send nothing) rather than guess. |
devices |
array | One entry per registered device. Empty means no notifications. |
token |
string | The device's APNs device token, lowercase hex. Unique within the file per session: a device with Hosts on several herdr sessions of this user has one entry per session. |
key |
string | Raw 32-byte Notification Key, unpadded base64url. Generated on the device, per Host. |
session |
string | The herdr session the entry was registered for: "" for the default session (never the literal "default"), otherwise the session name. See herdr sessions. Entries without a string session were written by older apps and receive every session's deliveries. |
env |
string | production or sandbox: which APNs environment the token belongs to, following the app build that registered it. |
notify.blocked |
boolean | Send a push when an Agent becomes Blocked. |
notify.done |
boolean | Send a push when an Agent reaches Done. A missing flag means do not send (fail closed). |
live_activity |
object | Optional per-device Live Activity registration. Present while the app is showing this Host's activity. See Activity hook. |
live_activity.token |
string | The per-activity APNs push token, lowercase hex. Distinct from the alert token. |
live_activity.started_at |
string | ISO 8601 timestamp the app wrote when it started (or rotated) the activity. Ignored by the hook; preserved on rewrite. |
live_activity.pinned_pane_ids |
string[] | Optional. Pane ids the device has pinned, most-recently-pinned first. Reorders Live Activity rows; does not change eligibility or counts. Missing, null, non-array, or any non-string entry is ignored (treated as empty). |
Readers ignore unknown fields (additive v1 metadata); breaking changes bump
v, honored by plugin and app together.
Every herdr session of one remote user runs this plugin's hooks with the same
config and state directories, so all sessions read the same
notifications.json, and pane ids repeat across sessions. Each hook process
derives its own session from HERDR_SOCKET_PATH (src/session.js), checked in
this order:
- Unset or empty: the default session (
""). - Ends with
/herdr/sessions/<name>/herdr.sockand<name>is a valid herdr session name (1 to 64 bytes of ASCII letters, digits,.,_and-, not.or..): that name. This comes first, so a session namedherdris not mistaken for the default. - Ends with
/herdr/herdr.sock: the default session (""). - Anything else (for example a custom socket override): unknown.
A hook delivers alerts and Live Activity updates only to entries whose
session equals its own, plus legacy entries without a string session,
which every session delivers to as before. A hook whose session is unknown
delivers to legacy entries only. Names compare exactly (case-sensitive), and
the name default gets no special treatment. The delivery check also runs
before the debounce, so a session with no entries to deliver to neither sleeps
nor calls herdr to send anything.
Per-session hook state lives under HERDR_PLUGIN_STATE_DIR/sessions/<name>/
for a named session (notify/ dedupe markers, activity/claim.json,
activity/last-state.json), so one session's dedupe, debounce claim, or ended
Live Activity never affects another. The default and unknown sessions keep
the paths earlier plugin versions used, directly under
HERDR_PLUGIN_STATE_DIR. A 410 Unregistered still prunes by token across
every session (see the hook sections below).
test-vectors/notification-session-v1.json pins the name rule, the socket
path derivation, and the delivery rule; the Node tests here and the Swift
tests in the app both read it.
Bump version in herdr-plugin.toml, package.json and package-lock.json
together with test-vectors/plugin-version-v1.json and
HeelerPluginCompatibility.bundled in the app; the Node and Swift tests
check that they agree. The app recommends updating any Host whose plugin is
older, and its per-feature notes (HeelerPluginFeature) trust the version
alone, so bump it with every change the app relies on rather than shipping
behavior under an unchanged number, as 0.3.0 and 0.4.0 did.
Before each plugin release, refresh and review the bundled price table.
Once the bump is on main, run make plugin from the repository root to
publish it to the plugin branch, before the app release that bundles it:
the app's update notice installs from that branch. See
Publishing the herdr plugin.
- A 0.6.0 plugin with an app that does not write
sessionbehaves as before: every entry is legacy and every session delivers to it. - An app that writes
sessionwith a plugin older than 0.6.0: the old plugin ignoressessionand delivers every entry from every session, so Hosts on different herdr sessions of the same remote user receive each other's notifications (opening the wrong pane), and one event can arrive as two notifications. Update the plugin on each Host to fix this. - Downgrading the app on a device after upgrading mixes up notifications of Hosts that share a server; after upgrading again, re-check each Host's Notifications toggle.
- Plugin 0.6.0 rejected some sidebar settings that herdr 0.9 accepts
(
letta,machine, tokenrules, a UTF-8 byte-order mark) and wrote the default layout and sort with a diagnostic. After the update, the next refresh, at server start or the next Agent Status transition, replaces that snapshot without a config edit. - A Host whose rows Sync from plugin replaced while 0.6.0 rejected its settings keeps the default fields it saved; sync it again.
- Plugin 0.6.2 reads
addressesfrompair.jsonand offers them first in the pairing checklist. Older plugins ignore the field and list only the machine's own interfaces, so a Host behind a port mapping needs 0.6.2 to pair. Withoutaddressesthe checklist is unchanged.
- Plugin 0.7.1 emits a Pairing Code v2 from a herdr that runs outside
~/.config/herdrand readslauncherfrompair.json. Older plugins emit v1 there, which pairs the phone with the user's default herdr; re-pair such a Host after the update. A herdr in~/.config/herdrpairs as before.
Plugin 0.7.0 adds Usage for Claude Code and Codex on Linux and macOS Hosts. Open the Host's Usage section in Heeler, then View Usage Details for today, 7-day and 30-day Token Usage, a daily chart, per-model totals and the top 10 working directories. Usage Limits show the account's windows and reset times; Codex shows only its default limit group.
The plugin reads Transcripts locally. Transcript contents stay on the Host; only aggregated usage, model names and directory paths reach Heeler over SSH. Token Usage makes no network requests. For Usage Limits, the plugin asks the installed programs, which handle their own sign-in and service requests. Neither the plugin nor Heeler reads or receives their credentials.
The plugin learns its runtime from herdr the first time any Agent changes status, or when herdr starts or restarts. Installing or updating the plugin does not run a startup hook. If Usage asks for a runtime, use any Agent on the Host once, then refresh Usage; restarting herdr also records it. Queries return cached aggregates immediately and start a background refresh when needed. Heeler re-queries while that refresh runs.
Claude Code limits is off by default and saved per Host on the device.
The toggle is available in both the Host's Usage section and the full Usage
page. Enabling it asks the Host's Claude Code for its plan limits through
the undocumented get_usage control request. This automated use of the
subscription is not officially supported; using Claude Code's own sign-in
does not establish that the use is permitted. Turning it off hides Claude
Code limits and leaves Token Usage and Codex limits available. On macOS,
Claude Code's sign-in is in the login keychain, which SSH sessions cannot
use, so the plugin's usage-claude-limits action reads the limits in
herdr's session. Start herdr from a terminal on the Mac, not over SSH.
Current terms remain a pre-release check in the
design's open questions.
Token Usage covers at most 30 days and only the Transcripts still on the Host. The page reports coverage, skipped compressed archives and Transcripts it could not read. Estimated API cost uses a bundled price table, not subscription billing; the page shows its date and any unpriced tokens. Models or service tiers without a known price stay unpriced. See the design and ADR 0028 for collection, cache and privacy boundaries.
Before each plugin release, run this from the repository root:
node scripts/generate-usage-pricing.mjsReview the generated pricing.json diff, including
the price date, source URL/hash, model coverage and service-tier rates.
The generator uses LiteLLM, falling back to models.dev if that download
fails; --source models-dev selects the fallback explicitly. Review its
coverage because absent service-tier prices remain unpriced. Keep
pricing.LICENSE aligned with the selected source.
The worker only reads the bundled table; it downloads no prices at runtime.
For an offline, reproducible update, use --input FILE --date YYYY-MM-DD
with saved source bytes, plus --source models-dev if those bytes use the
fallback format. After reviewing the table, run from the repository root:
npm ci --prefix plugin
node --test plugin/test/usage-pricing.test.js plugin/test/usage-query.test.js
UPDATE_USAGE_VECTOR=1 node --test --test-name-pattern='shared v1 vector' plugin/test/usage-worker.test.js
node --test --test-name-pattern='shared v1 vector' plugin/test/usage-worker.test.jsReview the shared usage-v1 vector diff and the
Swift literal price expectations in
AgentUsageTests.
If fixture costs change, update and run AgentUsageStoreTests using
the app test lane; keep the
producer, vector and Swift expectations synchronized.
src/sidebar-hook.js runs through [[startup]] at server start and live
handoff. It needs only HERDR_PLUGIN_CONFIG_DIR; startup has no
HERDR_PLUGIN_EVENT_JSON. Startup hooks are supported by herdr 0.7.5, so the
minimum herdr version stays 0.7.5.
The hook reads the config using the server environment inherited by plugin processes, in this order:
HERDR_CONFIG_PATH, when set, is the exact source path.- Otherwise,
XDG_CONFIG_HOME/herdr/config.toml, whenXDG_CONFIG_HOMEis set. - Otherwise,
HOME/.config/herdr/config.toml(the system temporary directory replacesHOME/.configwhenHOMEis unset).
Named sessions share this source; they do not add a session directory to its
path. The plugin parses TOML with smol-toml, validates the sidebar agent
layout and sort, then writes HERDR_PLUGIN_CONFIG_DIR/sidebar.json, next
to notify.json. It never writes this snapshot to HERDR_PLUGIN_STATE_DIR.
Writes use an exclusive mode 0600 temporary file followed by atomic rename;
a newly created plugin config directory has mode 0700.
A UTF-8 byte-order mark is handled as herdr 0.9.4 handles its usual
placements: a leading U+FEFF is stripped, and a U+FEFF at the start of a
later line is removed only when the parser's error points at it, after which
parsing is retried. A U+FEFF that is string data, such as one at a line start
inside a multi-line string, parses and is kept. herdr 0.9.4 also removes a
U+FEFF elsewhere in a line, for example after indentation or after =, when
its parser's error points there; the plugin reports parse_error for such a
file.
smol-toml implements TOML 1.1, while herdr 0.9.4 reads TOML 1.0, so the
plugin accepts some files that herdr rejects: a newline or trailing comma
inside an inline table and the \e and \xHH string escapes pass here
although herdr config check reports them. herdr config check remains the
authority on whether herdr accepts a file.
{
"v": 1,
"generated_at": 1757040000,
"source": {
"path": "/home/u/.config/herdr/config.toml",
"found": true,
"mtime_ms": 1757039000123
},
"agent_panel_sort": "priority",
"sidebar": {"agents": {
"row_gap": 0,
"rows": [
[{"token":"state_icon"},{"token":"terminal_title_stripped","fg":"#6c7086","dim":false}],
[{"token":"$pin_icon"},{"token":"workspace","dim":true,"rules":[{"equals":"scratch","ignore_case":true,"hide":true}]},{"token":"agent"}]
],
"rows_by_agent": {"claude":[[{"token":"terminal_title_stripped"}]]}
}},
"diagnostics": []
}| Field | Type | Meaning |
|---|---|---|
v |
integer | Snapshot version, currently 1. Swift treats any other version as absent. |
generated_at |
integer | Unix seconds when the snapshot was written. Unchanged sources retain their previous snapshot and timestamp. |
source.path |
string | Resolved herdr config path. |
source.found |
boolean | Whether the source was found. Missing source is false; a source that can be statted but cannot be read is true with a diagnostic. |
source.mtime_ms |
number or null | Source mtimeMs, including fractional milliseconds; null when the source cannot be statted. |
agent_panel_sort |
string | spaces (default) or priority; the input alias workspaces normalizes to spaces. Independent of Heeler's flat / by-Host grouping. |
sidebar.agents.row_gap |
integer | 0..65535, default 0. herdr's blank terminal rows between Agent entries, not between lines of one entry. |
sidebar.agents.rows |
token[][] | Whole default layout, at most 16 rows and 16 tokens per row. Empty layouts and rows are valid. |
sidebar.agents.rows_by_agent |
object | Canonical Agent id to whole replacement rows; default {}. Unlisted Agents use rows. |
diagnostics |
string[] | Empty on success or a missing source. Otherwise parse_error, invalid_token, invalid_schema, or read_error. Raw config values and parser excerpts are never included. |
Every token is an object, including TOML string tokens. Builtins are
state_icon, state_text, machine, workspace, tab, pane, agent,
terminal_title, and terminal_title_stripped. Custom names must match
^\$[A-Za-z0-9_-]{1,32}$ and retain their $ in the snapshot. A styled TOML
table accepts only token, fg, bold, dim, and rules: fg must be
#RGB or #RRGGBB; bold and dim must be booleans. Optional style fields
appear only when explicitly provided, preserving false and the original hex
case. Other config fields are ignored. This is validation of the exported
subset, not a substitute for herdr config check for unrelated herdr
settings.
rules is an array of at most 16 tables; herdr uses the first rule that
matches the token's text, and a matching rule with hide = true hides the
token. Every rule has exactly one condition: equals, contains, or
starts_with with a string, or gt or lt with a finite integer or float.
ignore_case must be a boolean and is allowed only next to a text condition,
so even ignore_case = false beside gt or lt is invalid. A rule's fg
follows the token fg grammar, and bold, dim, and hide must be
booleans; any other key is invalid. Rules need a text-valued token, so
state_icon accepts only rules = []. herdr 0.9.0 does not accept hide,
which herdr 0.9.1 added.
The snapshot exports each rule with only the fields it gave, in the order
equals, contains, starts_with, gt, lt, ignore_case, fg, bold,
dim, hide. Thresholds are JSON numbers, so gt = 80 exports 80. A
token with rules = [] exports no rules field. The snapshot version stays
1: Swift readers ignore rules, so a rule never restyles or hides a token
in Heeler.
rows_by_agent accepts the 24 canonical ids verified against herdr v0.9.4:
pi, claude, codex, gemini, cursor, devin, agy, cline, omp,
mastracode, opencode, copilot, kimi, kiro, droid, amp, grok,
hermes, kilo, qodercli, qwen, letta, maki, and muse. Aliases,
case variations, and unknown ids are invalid. A future herdr release that
adds canonical ids, built-in agent tokens, styled-token keys, or rule fields
requires updating the validator, this README and the shared vectors together.
The validator follows herdr's grammar for these fields but is not a complete
copy of it: herdr also accepts a few unusual shapes that the plugin rejects
with invalid_schema, such as ui = [], agents = [] under [ui.sidebar],
or a rule written as an array (rules = [["x"]]).
Missing files or fields use the snapshot defaults: sort spaces, row_gap: 0,
rows: [[{"token":"state_icon"},{"token":"workspace"},{"token":"tab"}], [{"token":"agent"}]], and rows_by_agent: {}. These are not herdr 0.9's
defaults: herdr 0.9.0 and later put machine after state_icon in the
first row, which Heeler does not render, and the snapshot defaults leave it
out. TOML parse errors, invalid tokens, or invalid schema revert the
entire layout and sort to those defaults with a diagnostic. Read errors
also produce defaults with a diagnostic and are retried at the next
invocation.
Both notify-hook.js and activity-hook.js refresh at the beginning of
main(), before debounce, dedupe, or no-device exits. An unchanged source
path, existence, and mtimeMs reuses a snapshot without diagnostics, without
reparsing or rewriting. A snapshot with diagnostics is parsed again at every
refresh, so an updated plugin replaces an older plugin's rejection without a
config edit, and it is rewritten only when the result changes. A
missing/corrupt snapshot is rebuilt. Other changes that preserve the source
mtime are not detected. Snapshot write failures are logged but do not prevent
the event hooks from delivering notifications.
There is no watcher and config reload does not run startup hooks. A config
edit with no Agent Status transition is picked up at the next transition or
server start. The snapshot reflects the file on disk, not any live TUI
agent.view.set sort override. It is this Host's own herdr configuration: a
herdr client elsewhere that has this Host as a saved machine renders its
sidebar from that client's own settings, as herdr's configuration docs
describe. Heeler updates this plugin only when the user asks on the Host's
page; older installations have no sidebar.json, which the app treats as nil
without a version gate.
Swift readers ignore unknown fields and drop individual unknown token names,
such as machine. The app resolves whole layouts in this order: the Host's
saved user layout, the Host's plugin snapshot, Heeler default. There is no
global user layout, and the default is never offered as a choice. Rows are
never merged across sources. Its renderer skips state_icon and
state_text because the status column already shows Agent Status; $name
resolves as plain text from AgentInfo.tokens["name"]. Heeler's Pin is
independent. Styles are retained in the Swift model; rendering may ignore
them (color is ignored by default).
test-vectors/sidebar-layout-v1.json is shared by the Node parser tests and
the Swift snapshot decoder tests, and registered as a HeelerTests resource.
Its valid and invalid arrays contain name, toml, and a full expected
snapshot; invalid entries also contain the expected error diagnostic.
Node asserts normalized layout equality and whole-layout fallback. Swift
consumes the structured snapshots to assert decoded layout, defaults, and
diagnostics, expecting the app to drop token names it does not model, such as
machine, and to ignore rules; it does not parse TOML. A U+FEFF in a
vector is written as the JSON escape \ufeff, so it stays visible. Change the
contract and vectors together.
The manifest [[events]] hook on pane.agent_status_changed runs
src/notify-hook.js as one short-lived process per Agent Status transition;
there is no long-running watcher. Only Blocked and Done notify, and
only for devices whose registration entry sets the matching notify flag.
Anti-noise, in order:
- Debounce: the script sleeps ~5 s, re-checks the pane's current status
through
HERDR_BIN_PATH(herdr agent get <pane>), and aborts if the status moved on (or the agent is gone). - Dedupe: the last notified status is recorded per pane under
HERDR_PLUGIN_STATE_DIR/notify/(HERDR_PLUGIN_STATE_DIR/sessions/<name>/notify/for a named herdr session); a same-status repeat sends nothing. A different status that survives its own debounce re-arms the pane.
Each eligible device gets one POST https://heeler-apns.bybee.dev/push by
default (see relay/README.md), carrying the encrypted envelope and an opaque
per-pane collapse key (derived from the device's Notification Key and the
pane id, so the relay cannot guess the pane while newer statuses still replace
older notifications). Transient failures (network errors, 429, 5xx) are
retried up to 3 attempts; a 410 Unregistered verdict prunes every entry
with that token from notifications.json, whatever its session (preserving
any fields this plugin does not understand);
other 4xx verdicts are final.
Plugin-side settings live in notify.json next to the registration file in
the plugin config dir:
| Field | Type | Meaning |
|---|---|---|
relay_url |
string | Optional Push Relay base URL override for a self-built app. Defaults to https://heeler-apns.bybee.dev; the app writes the resolved value during Notification Registration. |
debounce_ms |
integer | Debounce sleep override for the alert notify hook. Default 5000. |
activity_debounce_ms |
integer | Latest-wins debounce sleep for the Live Activity hook. Default 1500. |
retry_delay_ms |
integer | Delay between retry attempts. Default 1000. |
HERDR_PLUGIN_EVENT_JSON as captured empirically from a live herdr 0.7.5
event hook invocation — note the event value is snake_case on the wire even
though the manifest subscribes to the dot name:
{"event":"pane_agent_status_changed",
"data":{"type":"pane_agent_status_changed","pane_id":"wV:p1",
"workspace_id":"wV","agent_status":"blocked","agent":"claude"}}agent (plus title, display_agent, state_labels per herdr source) is
optional. herdr's wire shapes carry no stability guarantee, so the hook parses
leniently: it requires only data.pane_id and data.agent_status and ignores
everything it does not recognize.
The second manifest [[events]] hook on pane.agent_status_changed runs
src/activity-hook.js as its own short-lived process. herdr invokes both
this command and src/notify-hook.js for the same event (verified on
0.8.0); there is no in-plugin dispatcher. It is independent of the alert
notify hook: notify flags do not gate it, and a Host with no
live_activity registration delivered to this hook's
herdr session sends nothing. The activity/ paths below
are relative to the session's state directory.
The hook lists the Host's agents through HERDR_BIN_PATH (herdr agent list),
resolves workspace labels best-effort through herdr workspace list, and drives
one Live Activity per Host. Eligible statuses are Working,
Blocked, and Done; idle and unknown panes are hidden. The encrypted
details envelope uses the same Notification Key as alerts, sealed under AAD
HERDR-ACTIVITY:1 (see docs/agents/live-activity-contract.md and
test-vectors/live-activity-content-v1.json).
Anti-noise, in order:
- Cheap exits: no device has a
live_activityobject with a plausible hex token, valid env, and 32-byte key; oractivity/last-state.jsonrecordedended: trueand the incoming status is not working/blocked/done. - Latest-wins debounce: the script writes
activity/claim.jsonand sleepsactivity_debounce_ms(default 1500). A newer claim from an overlapping invocation wins; the loser exits without sending. - Unchanged snapshot: a
{pane_id: status}map identical to last-state sends nothing.
An empty eligible set sends event: end (skipped if already ended) with zero
counts, dismissal_date equal to the timestamp, and an envelope whose
agents array is empty. Otherwise it sends event: update with
stale_date = timestamp + 900. Priority is 10 only when some pane is
blocked now and was not blocked in last-state (absent last-state counts as
empty); otherwise 5.
Each eligible device gets one POST /push with kind: liveactivity, the
activity token, and the sealed envelope. Transient failures (network errors,
429, 5xx) are retried up to 3 attempts. A 410 Unregistered verdict deletes
only that entry's live_activity field, preserving the alert token, key,
notify flags, and any field this plugin does not understand. A relay-origin
413 degrades the envelope once per step (drop every title while retaining
the displayed workspace/kind/status, then send agents: []) and retries;
the hook also pre-degrades when the projected ciphertext would exceed the Live
Activity size budget.
Last-state (activity/last-state.json: sent_at_ms, statuses, ended) is
written only after at least one successful delivery. All state writes are
temp-file + rename.
npm testUses Node's built-in test runner; no test dependencies.