beady-eye
bdi shows the work in flight across your beads
trackers as a tree, with the live herdr agent drawn beside
each bead it is working on.
beads knows the work: the tree, the dependencies, each bead's status and who
claimed it. herdr knows the agents: which pane is alive and what it is doing.
Neither knows about the other, so "what is left, what is done, and who is on
what right now" has no single answer — and a closed bead whose agent never
exited, or a claim whose agent died, is invisible to both. bdi joins them.
Invented ground: the atlas project, its work and its panes are all made up,
and tools/capture/ is what draws the frame. atlas-5 is the drift — a claim
with no pane behind it, which is the thing neither beads nor herdr can see on
its own.
bdi only reads. Changing the work stays bd's job.
Install
From crates.io:
$ cargo install beady-eye
With Nix, run it without installing:
$ nix run github:CodeForBreakfast/beady-eye
or keep it:
$ nix profile install github:CodeForBreakfast/beady-eye
Neither is a download. Nothing publishes a binary cache for this project, so
the first run compiles it from source and takes minutes. And both follow
main, so what they build is the tip of the default branch rather than the
last release.
They also need flakes, and a Nix without them refuses twice — once per feature, and obeying the first refusal does not clear the second:
$ nix run github:CodeForBreakfast/beady-eye
error: experimental Nix feature 'nix-command' is disabled; add '--extra-experimental-features nix-command' to enable it
$ nix --extra-experimental-features nix-command run github:CodeForBreakfast/beady-eye
error: experimental Nix feature 'flakes' is disabled; add '--extra-experimental-features flakes' to enable it
Ask for both at once:
$ NIX_CONFIG='experimental-features = nix-command flakes' nix run github:CodeForBreakfast/beady-eye
or write that same line into nix.conf and the prefix stops being needed. The
flag nix itself suggests does the same, as long as both features are named at
once — naming them one at a time is the loop above.
To use it from your own flake, pin the input to a release tag — which is how you get a build you can name afterwards — and take either the package or the overlay:
inputs.beady-eye.url = "github:CodeForBreakfast/beady-eye/v0.2.0";
# then either
beady-eye.packages.${system}.default
# or
nixpkgs.overlays = [ beady-eye.overlays.default ]; # pkgs.beady-eye
The crate is beady-eye; the command it installs is bdi.
On a Mac with neither cargo nor Nix, take the binary from the latest release.
It is one fetch and a chmod:
$ curl -fLO https://github.com/CodeForBreakfast/beady-eye/releases/latest/download/bdi-aarch64-apple-darwin
$ chmod +x bdi-aarch64-apple-darwin
$ ./bdi-aarch64-apple-darwin --version
aarch64-apple-darwin is an Apple silicon Mac and x86_64-apple-darwin an
Intel one. Move it somewhere on your PATH under the name bdi and the rest of
this page reads as written.
A .sha256 sits beside each one, and it names the file it is for, so keep that
name to check it:
$ curl -fLO https://github.com/CodeForBreakfast/beady-eye/releases/latest/download/bdi-aarch64-apple-darwin.sha256
$ shasum -a 256 -c bdi-aarch64-apple-darwin.sha256
The binaries are not signed by Apple. Gatekeeper decides on a
com.apple.quarantine attribute that whatever downloaded the file puts on it,
and curl puts none there, so a binary fetched the way above runs. A browser
puts one on, and macOS then refuses to run the file and asks you about it
instead. Clearing the attribute is what gets past that:
$ xattr -d com.apple.quarantine bdi-aarch64-apple-darwin
Run it
Inside a repository beads tracks, bdi needs no config:
$ cd ~/atlas
$ bdi
It draws that project's trees and keeps them live. The screen has three bands: the forest, with a line per project and its trees under it; a tail showing the last rows of the selected bead's pane; and a foot row with notices on the left and keys on the right.
These are the keys to get started with; ? shows every binding there is:
| key | does |
|---|---|
↑ ↓, j k |
move up and down a row |
← →, h l |
collapse, or move to the parent when it is already collapsed; expand, or move to the first child when it is already expanded |
Enter |
show the selected bead, or focus its pane from the bead view |
f |
focus the selected bead's pane |
a |
show every tree, not only those with a live agent |
Space |
fold or unfold the selected node |
E, C |
expand or collapse the selected node and everything under it |
/, n, N |
find part of an id or title; next and previous match |
y |
copy the selected bead's id to the clipboard (OSC 52, so it works over ssh and through a multiplexer) |
^R |
read the trackers again now |
? |
every binding |
q |
quit |
bdi --json writes the same snapshot to stdout instead of drawing it. That is
also what to use when stdout is not a terminal — bdi | cat says so and exits.
Outside anything beads tracks, and with no config file, there is nothing to read:
$ bdi
Error: there is no config at /home/you/.config/beady-eye/config.toml, so bdi read the current directory
Caused by:
/home/you is not in anything beads tracks
Which projects a run reads
With a config naming several projects, the directory you start in decides.
Under one of them — its directory, a repository inside it, or a linked worktree
— bdi reads that project alone and says so on screen. Anywhere else, it reads
all of them.
--all-projects reads every configured project from anywhere. --project NAME
(repeatable) reads only those, from anywhere. A bead named on the command line
as PROJECT:ID adds its tree to the run, and reads that project if the
directory would have left it out.
Configure it
bdi reads ~/.config/beady-eye/config.toml, or the file --config names,
and re-reads it while running: an edit takes effect a couple of seconds later.
A file that does not parse leaves the previous config in force and says so at
the foot until it is fixed.
Everything has a default except the project list:
[[]]
= "atlas"
= "/home/you/atlas"
[[]]
= "orbital"
= "/srv/work/orbital"
= "nix develop -c"
[[]]
= "beacon"
= "/home/you/dev/beacon"
= "secret-tool lookup tracker beacon"
[]
= ["atlas-1", "atlas-10"]
[[]]
= "delivery_pr"
= "⇢ {}"
[[]]
= "blocked_on"
= "human"
= "⏸ waiting"
[]
= "agent_pane"
[]
= "/run/user/1000/beady-eye/changes.sock"
[]
= 30
[]
= 30
= 30
= 250
[]
= "light"
[[projects]]
A name and the path of its repository. The name is how bdi tells one
tracker's beads from another's, so no two projects share one.
Without a config, the one project is named after the repository's origin
remote, or its directory if there is no remote or no git. BDI_PROJECT in the
environment overrides that name, which is how to keep one name across machines
that cloned into differently-named directories.
environment_command — the wrapper you would type yourself to enter the
project's environment, if bdi cannot work it out. A directory with an
.envrc, on a machine with direnv, needs nothing: bdi enters it with
direnv exec . on its own. Otherwise name the wrapper:
| entered with | write |
|---|---|
| nix | environment_command = "nix develop -c" |
| mise | environment_command = "mise exec --" |
direnv, from an .envrc somewhere else |
environment_command = "direnv exec ." |
The command runs in the project's directory. It is split on spaces with no quoting, so an argument containing a space is written as a list:
= ["nix", "develop", ".#dev shell", "-c"]
Whichever way it is entered, the tracker is read with the bd that environment
supplies — the one you would get by standing in the directory yourself. A
project that asked for an environment bdi could not produce is not read at
all, and the screen says so; on a fresh clone that is usually an .envrc
waiting for direnv allow. See What it needs for why there is
no fallback.
credential_command — a command whose stdout is the tracker's password.
It runs inside the project's environment, and its output is captured rather
than passed on a command line, so the password never shows in ps.
poll = false — stop polling this project and rely on something
telling bdi when it changed. Nothing then
covers for a producer that dies, which is deliberate: an automatic fallback
would hide the failure.
[roots.explicit]
Trees to draw beyond the ones bdi finds for itself, listed under the project
whose tracker holds them. Bead prefixes are per-tracker, so an id has to be
placed.
[[badges]]
Draw a metadata key beside every bead that carries it. render is the text,
with {} for the value; match restricts the badge to one value. bdi has no
idea what your metadata means — a convention your setup encodes there is named
here and drawn as written.
[join]
pane_key is the metadata key that names the herdr pane an agent sits in. It
ties an agent to its bead exactly, rather than guessing from what the pane calls
itself.
[changes]
socket is where bdi listens for something saying a project's work has
moved. It defaults to $XDG_RUNTIME_DIR/beady-eye/changes.sock, and a machine
with no $XDG_RUNTIME_DIR has no channel until this names one. --socket
overrides it for one run, which is how two bdi runs on one machine each get
a channel. Telling bdi where to listen, under Telling bdi a project
changed, has the whole of it.
[anomalies]
stale_claim_days is how long a claim may go untouched before bdi flags it.
The default is bd stale's own window.
[tui]
Three intervals, each a gap after an answer rather than a fixed period, so a
slow tracker stretches its own gap instead of queueing reads behind itself.
refresh_seconds is how long a project waits between reads;
unanswered_after_seconds is how long a read may take before the screen says
the tracker has stopped answering; tail_refresh_millis is how often the tail
asks herdr for the selected pane.
[theme]
background is dark or light. bdi cannot see your terminal's background
and assumes dark; on a light one the tail band becomes hard to read until you
say so.
Telling bdi a project changed
bdi polls, and most polls find nothing moved. A poll first asks the tracker
whether anything has changed (one bd sql for the Dolt working root) and only
reads in full if it has. That probe needs a Dolt server; bd's embedded store
refuses it, and bdi then reads in full on every poll.
Anything that already knows a tracker changed can skip the wait. bdi listens
on a stream socket, created mode 0600 and removed on exit —
$XDG_RUNTIME_DIR/beady-eye/changes.sock unless it is told otherwise. Write a
project's name as one line; bdi reads that project now and answers on the
same connection:
| answer | meaning |
|---|---|
ok <project> |
read again now |
unknown <project> |
not a project this run is reading |
malformed |
blank, or over 512 bytes |
A connection can carry as many lines as you like and stay open for as long as the writer does. A project that is reported for is never polled — each report pushes the next poll past its interval — and one whose producer goes quiet is polled again from one interval later. The view degrades to slow, never to stale.
The cheapest producer is a wrapper round bd itself. It reads the default
path; a bdi told a different one has to be told to the producer too.
nc -N -U "$sock" does the same with OpenBSD netcat. A Dolt trigger, a git
hook, a systemd path unit or a cron job comparing a head hash all work equally
well; bdi provides the socket and cannot tell them apart.
--poll and --no-poll override every project's poll setting for one run,
which is how to find out whether a suspect producer was the only thing wrong.
Telling bdi where to listen
The default path is one per login session, so two bdi runs on one machine
derive the same one and the second finds the first already listening. It says
so on stderr and polls everything for the rest of its life: the socket is asked
for once at startup and never again, so closing the first run frees the path
for the next run rather than for this one. Give one of them a socket of its own
and both have a channel:
$ bdi --socket /run/user/1000/beady-eye/worktree.sock
--socket is per run, which is what two simultaneous runs of one binary need:
a config file is per user, so both of them read the same one.
A machine with no $XDG_RUNTIME_DIR — macOS has none — has no path to derive
and no channel until it is told one. It wants the same path every run, so it
belongs in the config:
[]
= "/Users/you/Library/Caches/beady-eye/changes.sock"
--socket overrides the key.
Two things are worth knowing for a path you name rather than for the default,
because $XDG_RUNTIME_DIR is a directory no other user can reach and a path
you name may sit somewhere any of them can walk through.
The socket is created 0600 wherever it goes, and both Linux and macOS check
that mode when something connects, so the channel is yours to speak on either.
Who may replace the socket is for the directories above it to say. bdi
creates a directory it makes 0700 and takes one already there as it stands,
and it reads every directory on the way down, both as you spelled it and as it
resolves. Each has to be yours or the system's, and closed to everybody else —
or sticky, which is how /tmp keeps each name for whoever made it. Where one
of them is a directory somebody else may take a name in, bdi names that
directory and polls.
So /tmp/beady-eye/changes.sock is a channel. /tmp keeps each name for
whoever made it, and bdi makes the directory under it and keeps that to you.
A path already holding something that is not a socket is refused, and what is
there is left alone. bdi clears away the socket a crashed run left behind,
and a name one keystroke from a file you need would otherwise be cleared away
the same way.
If the socket still cannot be opened — no path to put it at, or another bdi
already listening on the one it has — bdi says so on stderr at startup, names
the remedy, and polls everything.
What it needs
Linux or macOS. bdi listens on a unix socket and waits on unix signals,
so those are the two platforms it runs on. CI builds and tests on Linux; macOS
is built and tested by hand.
macOS has no $XDG_RUNTIME_DIR, so the channel that tells bdi a project
changed has nowhere to derive a socket from and has to be given a path: the
[changes] key, or --socket for one run. Until it has one, a Mac polls every
project, which is slower and never wrong. Telling bdi where to listen has
the paths.
A terminal that honours OSC 52, to copy with y. The copy is that escape
sequence and nothing else, which is what carries it through ssh and a
multiplexer. A terminal that does not honour it drops the sequence and tells
nobody: the foot says copied and the clipboard is unchanged. Apple's
Terminal.app is one of those. Nothing else on the screen needs it.
bd 1.1.0 or newer. An older bd is reported as such on the project's line,
rather than as a tracker that cannot answer. A packaged bd is not always
current: nixpkgs' beads was under this floor on current stable and on
unstable alike, read on 2026-09-07. Check the version yours reports.
A tracker bd can open, server or embedded. bdi speaks to no database; it
asks bd, so what it reads is what bd reads. A tracker on a Dolt server
authenticates, and the password reaches bd in BEADS_DOLT_PASSWORD — from the
shell bdi was started in, or from that project's credential_command. The
store bd init makes is embedded Dolt, authenticates to nothing, and needs
neither.
The two part company over the cheap question of whether anything moved, which
is a bd sql statement the embedded store refuses. bdi learns that from the
first refusal of a run and reads such a tracker in full on every poll instead.
That is slower and never wrong, and it shows against a tracker being written
hard: bdi's read waits behind the writes rather than either side failing, and
a read taking longer than unanswered_after_seconds is reported as a tracker
that has stopped answering.
Each tracker read by its own bd. bdi never writes to a tracker, but bd
does: on finding itself newer than the bd that last opened a tracker, bd
rewrites .beads/.local_version and migrates the schema, before running
whatever subcommand it was given. --readonly does not stop that, and under
--json bd says nothing about it. So a tracker read with a bd that is not its
project's can be moved to a schema its project's bd cannot open — which is why
bdi runs each project's own bd through the environment ladder above, and
refuses to fall back to its own when that fails. Upgrading a project's bd
migrates on the first read afterwards; that is the upgrade you chose.
docs/design.md has the measurements.
git, optionally. bdi uses it for the repository a directory sits in, the
origin name, and the linked worktrees. Without git it still reads the
tracker, names the project after its directory, and cannot place a pane by
worktree.
herdr, optionally. Without it you get the trees, the counts and the claims, with every tree drawn since there are no agents to filter on. herdr adds the part this is for: which claim has a live pane behind it, which pane is working on nothing any bead accounts for, and the tail.
Status
Released, and in daily use against the trackers it was written for. The design is in docs/design.md.
Versions are 0.x, and a breaking change bumps the minor: 0.1 → 0.2. So a
minor bump can break you — pin the input to a release tag, as the example above
does. 1.0.0 is a version the maintainers will choose once the shape has
settled, rather than one a change arrives at by breaking something.