beady-eye 0.1.0

A tree of work in flight: bead graphs annotated with the live agents working them
Documentation

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.

▾ atlas  ✓ 29s ago                                                   3/12  3 agents  ⚠ 1
  ├── ○ atlas-1   Payments move to the new gateway                                   2/9
  │   ├── ◐ atlas-3   The refund path calls the gateway          0/3  ◍ wE3:pE · working
  │   │   ├── ◐ atlas-5   Retire the old refund worker               ⚠ claimed · no pane
  │   │   └── ○ atlas-4   Backfill the refund ledger
  │   ├── ◐ atlas-7   Webhook retries are not idempotent              ◍ wE3:pD · working
  │   ├── ○ atlas-2   Pin the gateway client version
  │   ├── ○ atlas-6   Cut the live keys over                                         0/2
  │   │   └┄┄ ○ atlas-2   Pin the gateway client version
  │   ├── ✓ atlas-8   Reconcile the settlement report
  │   └── ✓ atlas-9   Drop the gateway shim
  ├── ○ atlas-10  Search returns stale results after an edit                         1/3
  │   ├── ◐ atlas-11  Invalidate the index on write                   ◍ wE3:pF · working
  │   └── ✓ atlas-12  Measure the reindex cost
  └── ⚠ 3 unattributed panes
────────────────────────────────────────────────────────────────────────────────────────
  no pane · nobody is working this bead





Enter show   a all   ? keys   q quit

A real run at 88 columns, against a throwaway tracker of made-up work joined to the panes that were alive on the machine. atlas-5 is the drift: a claim with nothing behind it.

bdi only reads. Changing the work stays bd's job.

Install

Nothing is published yet. These are the commands the first release tag will make work.

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

To use it from your own flake, pin the input to a release tag and take either the package or the overlay:

inputs.beady-eye.url = "github:CodeForBreakfast/beady-eye/v0.1.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.

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.

key does
arrows, hjkl move
Enter open the selected bead
f bring its pane to the front
a show every tree, not only those with a live agent
Space, E fold or unfold a node; expand it 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:

[[projects]]
name = "atlas"
path = "/home/you/atlas"

[[projects]]
name = "orbital"
path = "/srv/work/orbital"
environment_command = "nix develop -c"

[[projects]]
name = "beacon"
path = "/home/you/dev/beacon"
credential_command = "secret-tool lookup tracker beacon"

[roots.explicit]
atlas = ["atlas-1", "atlas-10"]

[[badges]]
key    = "delivery_pr"
render = "⇢ {}"

[[badges]]
key    = "blocked_on"
match  = "human"
render = "⏸ waiting"

[join]
pane_key = "agent_pane"

[anomalies]
stale_claim_days = 30

[tui]
refresh_seconds = 30
unanswered_after_seconds = 30
tail_refresh_millis = 250

[theme]
background = "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:

environment_command = ["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.

[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 $XDG_RUNTIME_DIR/beady-eye/changes.sock, a stream socket created mode 0600 and removed on exit. 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:

bdi_changed() {
  local sock="$XDG_RUNTIME_DIR/beady-eye/changes.sock"
  [ -S "$sock" ] || return 0
  printf '%s\n' "$1" | socat - UNIX-CONNECT:"$sock" >/dev/null 2>&1
}

bd() {
  command bd "$@" || return
  case "$1" in
    create|update|close|note|dep) bdi_changed my-project ;;
  esac
}

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.

If the socket cannot be opened — no XDG_RUNTIME_DIR, or another bdi already listening — bdi says so on stderr at startup and polls everything.

What it needs

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.

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

Built, unreleased, 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.10.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.