Status: describes bdi as built, reconciled against main on 2026-09-02
Date: 2026-08-30, reconciled 2026-09-02
Binary: bdi
This was the starting point, not gospel. Where it was ambiguous or wrong, the seat that hit the ambiguity decided it against the code in front of it and recorded what it chose; those decisions are folded back in here. Where a later change makes a sentence here false, the code is right and this document is the one to fix.
A measurement names whatever can falsify it. A date alone dates the
reading, not the thing read. A claim about bdi's own behaviour names the
commit it was measured at; a claim about bd, direnv or another tool names
that tool's version, because a bdi sha says nothing about it; a claim that
turns on this repository's own .envrc says so. Nothing checks this — nix flake check compiles code and nothing reads a claim — so it is a convention
for the writer, and the reader's cue that a figure with no such stamp is a
figure nobody can retire.
Work is tracked in beads and done by coding agents running in terminal panes. Two systems hold the truth and neither holds all of it:
- bd knows the work — the tree of beads, the dependency edges, each bead's status and who claimed it.
- herdr knows the agents — which pane is alive and what it is doing.
Nothing joins them, which costs two things.
No single view. bd graph renders the tree but knows nothing about agents.
herdr agent list knows the agents but nothing about the tree. To answer "what
is left, what is done, and what is being worked on right now" you read two
surfaces and hold the join in your head.
Drift between them is invisible. Observed live while writing this design:
| bead | bd says | herdr says |
|---|---|---|
smt-4kd3p.6 |
closed | pane wCM:p4 working |
smt-4kd3p.11 |
closed | pane wCM:pB working |
smt-4kd3p.16 |
in_progress | no pane carries this bead |
The first two are agents that finished and never exited. The third is a claim with no visible agent — or an agent that has not identified itself yet, and today those two are indistinguishable. That is the specific thing this fixes.
A tree of work rooted at a bead, with each node annotated by the live agent working on it. The work is the spine; agents are an annotation that is absent on most rows.
Every term comes from beads or herdr. Where both are silent, and only then, we coin one — and say so.
| term | source | meaning here |
|---|---|---|
| bead, root, epic | beads | the unit of work; the bead a tree hangs from; issue_type: epic |
| tree | beads | a root and its descendants — everything that must finish before it can (see Tree construction) |
| parent-child, blocks | beads (type on a dependency) |
the two edge kinds; a nesting is drawn from either, and the elbow says which |
| external dependency | beads (bd dep add across prefixes) |
a dependency on another project's bead, which the bead waiting on it holds (see Across projects) |
| orphaned dependency | beads (bd doctor's "Orphaned Dependencies" check) |
a dependency on a bead no configured project holds |
| claim | beads (bd update --claim) |
an agent taking a bead |
| stale | beads (bd stale) |
in-progress with no recent activity, "may be abandoned" |
| ready | beads (bd ready) |
open and every dependency satisfied |
| completed, progress | beads (bd swarm status) |
the finished count and the n/m roll-up |
| active | beads (bd swarm status) |
in-progress, an agent on it |
○ ◐ ● ✓ ❄ |
beads (bd list's legend) |
open, in_progress, blocked, closed, deferred — glyph for glyph, because a glyph is terminology. ? is bdi's for a status bd has no legend for |
◇ |
beads (format.StatusIcon) |
hooked. It is what bd draws beside a hooked row, though its printed legend leaves it out |
⊙ |
coined | pinned. bd's own mark is 📌, two cells wide where every other glyph is one |
| agent, pane, session | herdr | the worker; its terminal; the server holding them |
display_agent, agent_status, state_labels |
herdr | read verbatim, never renamed |
| snapshot | herdr (herdr api snapshot) |
one poll's whole state |
| ground | coined | the terminal's own foreground, untreated, which the liveness scale is measured from rather than being a step on. bdi holds a symbol and the terminal holds the value, so a scale with the default among its steps has one interval nobody can size — and a theme setting color7 or color15 to its foreground, which is how themes are written, closes that interval to nothing. Neither project has the concept: neither draws a scale. |
| badge | coined | a rendering of one value a bead carries, read either from its metadata or from a field of its own. beads has label, but a label is a bead's own tag; neither project has a display term for this. |
| drawn_on | coined | the rows a badge is drawn on: its own bead's alone, or also the shut row of each bead its bead blocks. Neither project draws a value on another bead's row. |
| unattributed | coined | a live pane in a configured project resolving to no bead. Neither project names this, because neither knows about the other. |
| unconfigured | coined | a directory no [[projects]] entry covers, and the panes working in it. bdi has not failed to attribute them; it was never told the project exists. |
| finished | coined | a branch every bead of which is closed, with no agent and no anomaly anywhere beneath it — the whole branch, not merely its head. beads has closed, which is one bead's status; this is a claim about a subtree. |
| run | coined | several finished siblings drawn as one counted line — the code calls the line elided and the siblings a run. Neither project draws one. |
| notice | coined | something true of the view as a whole rather than of any row in it, said at the foot of the screen. |
| freshness | coined | how stale one project's rows are, said beside its name: a mark for how the read of it is going, and how long ago the rows were last read. Neither bd nor herdr has a word for it. |
| armed | coined | a project set to ask to be read again at a known instant. Neither project names it: the ask is bdi's own. Armed by the read that came back and disarmed by the ask it makes, so a project always has a read outstanding or an ask armed — a project with neither is a project nothing will ever read again. A project with a producer and no poll is never armed. |
| reach | coined | the path and environment_command a project's config entry gives, which together decide the tracker bd reaches. Neither project names the pair. |
| covered | coined | a project something outside bdi has said it is watching, with nothing changed: a covered <project> line on the inbound channel. A covered line, and a read of the project coming back, each vouch for its rows for [changes] covered_for_seconds. A project that does not poll and that nothing has vouched for in that time has lapsed, and its mark says so. Neither project names this: the channel is bdi's own. |
| window | coined | how long a read is held after it is asked for before it is sent, so that a burst about one project costs one read. It runs from the first notification and is not reset by the ones after it: under reset a held-down ^R would withhold the read it exists to force. The screen says the read is coming when it is asked for, never when it goes. |
| way down | coined | the beads stepped through from a tree's root to a line. A bead reached more than once is drawn once per way down to it, and the way down is what tells the copies apart, what a fold and a selection are held by, and where a loop is cut. |
| spine | coined | the ways down a fold default opens, and the rule that chooses them. Every copy opens every way down to work a reader needs; a one-copy rule, deepest by default, opens one of them per bead and rests the rest shut over the same work. Neither project has the concept, because neither draws a tree that reaches a bead twice. |
| link | coined | one way down from a bead to a bead beneath it, as the tree holds it: which bead, by which kind of edge, and whether it is the way the walk first reached the bead. beads has the dependency; the link is the nesting drawn from it. |
| facts | coined | what a line says of the tree beneath its bead — its fraction, what it is shut over, whether it rests open, whether it is finished, what a run under it stands for — and what a project's line counts over its trees. Each depends on the snapshot alone, so the forest answers them once when it takes a snapshot and a keystroke reads them. Neither project has a word for an answer kept between draws. |
| ambient | coined | the environment bdi itself was started in, which is what a project's tracker is read in where neither the project's config nor its directory says how to enter it. Neither project names it: bd reads whatever environment it is given, and herdr never runs bd. |
| gone | coined | a bead its tracker held and has since answered without. The watcher's gone line names one, and so does the line standing where a focused bead's row was. Not a root its tracker never held, which is reported missing. Neither project has a word for a bead that has left a tracker's answer. |
| unanswered | coined | a read of a project that has been outstanding longer than one may be and has produced nothing. Neither project names it: the read is bdi's own, and neither bd nor herdr knows it is being waited on. Not refused, which is a read that came back and said no. Whether the read is the collection bdi is running or one queued behind it is not part of it — the reader's question is how long their rows have been on their way, and both answers to why are the same wait. |
| tail | coined | the band under the forest showing the selected pane's last rows, in the pane's own colour, read again on a clock of its own ([tui] tail_refresh_millis). herdr has agent read, which is the read; neither project names the band or its clock. |
| crop | coined | where the tail cuts a pane's screen, so that it shows the rows above the cut: a strategy bdi ships, which the reader's [tail.crop] names for each agent. herdr names the agent in a pane and reads its screen; neither project cuts one. |
| agent provider | coined | whatever answers which panes are alive, in which directory and showing what, and can bring one to the front. herdr is one; tmux, zellij and wezterm could each be another. Neither project names the category, because herdr is one of these rather than one that has one. |
| aside | coined | the agent provider held off the loop: the tail asks by sending, and the answer arrives later on the channel every other event arrives on. A provider that has wedged therefore costs one waiting thread rather than a keyboard that has stopped answering. Neither project names it, because neither is the thing being kept waiting. |
| block | coined | one of the three parts a row is fitted from: the identity, left-aligned and yielding last; the title, filling the middle and cut first; the state, right-justified and cut from its own end. Neither project has a word for it, because neither fits a row to a width. |
| watcher | coined | bdi watch, the one process per machine that holds each project's beads as last read and sends them, and each change to them, to whoever watches. Neither project names it: bd's events journal records changes, and nothing in either project routes them to whoever asked. |
| change source | coined | what hands the watcher each project's beads, how current they are, and bd's event records where the project keeps them. The first reads trackers, and another can replace it. Distinct from a producer, which only says a project changed. |
| gate, resolve | beads (bd gate) |
a bead that blocks another until something outside the tracker happens, and closing one. A gh:pr gate waits on a pull request, and bdi gates is named for these |
| delivery | GitHub (webhooks) | one webhook request GitHub sends, with its event and signature in headers. bdi gates --listen takes a pull_request delivery as a trigger to settle the pull request it names, and nothing more |
| settle | coined | act on a gh:pr gate once GitHub says its pull request has done what the gate waits for: resolve the gate on a merge, or on leaving draft for a gate with awaits=ready_for_review, or on approval for a gate with awaits=approved, and on a close without a merge, or on failing checks or a conflict with the base on the head commit, or on a submitted review, or on a comment on its conversation, comment on the beads it holds back, once each. beads has bd gate check, which resolves on a merge alone and acts on a close through an escalation bdi does not make |
| pseudopod | coined | one thing bdi does in the world rather than reads from it: a limb grown for one job, the way a shoggoth grows one from its own body. There are two: bdi bd's human respond, which carries a person's answer to the bead that asked, and bdi gates, which settles gh:pr gates. Changing the work itself stays bd's. Neither project names it: beads writes for whoever runs bd, and herdr writes to no tracker. |
| cell | coined | one named thing a bead's row draws, in whichever block the row's layout puts it: a built-in — glyph, id, title, badges, progress, agent, anomalies — or one badge as badge.<key>. Notes and the fold's counts are not cells; they trail the state whatever the layout says. Neither project names the parts of a drawn row. |
This is the trap the whole tool walks into, and one word for all three would make it unreadable.
| name it | source | means |
|---|---|---|
status: blocked |
a bead's own field | somebody set that status |
| not ready | bd ready, bd swarm status |
computed: an unmet dependency edge |
pane_status: blocked |
herdr agent_status |
a TTY prompt is waiting |
They are close to disjoint in practice. A bead can be status: open and not
ready; a bead can be status: blocked with every dependency satisfied. bd blocked reports the edge kind, not the status kind. The model keeps three
separate fields and the renderer never prints a bare "blocked".
bdi assumes bd and nothing else. herdr is an optional provider that adds
liveness. It knows nothing about any particular way of organising agents — no
orchestration model, no workflow, no roles. It reads a tracker, reads a session,
and draws the join.
Anything workflow-specific is expressed as configuration, not code. See Conventions are configuration below.
- No repairs to bd. It surfaces drift; you act. A viewer that also repairs
state is a second writer racing whatever else manages these beads. Its
pseudopods write only what someone outside
bdihas already decided: a person's answer, and what GitHub says became of a pull request. - No Noctalia widget. The JSON contract is specified here; no widget ships until the TUI has proved the data model.
- No cross-machine view. One box.
herdr --remoteis a later consumer of the same collector.
Three units, each testable alone.
bd CLI (per project) ──→ collector ─→ model ─→ ┬─→ TUI (ratatui)
herdr socket (optional) ─┘ (pure) └─→ --json (serde)
collector — the only unit that does I/O. Shells out, parses, joins. model — pure functions: tree construction, anomaly rules, ordering, filtering. All the logic worth a test lives here and needs neither herdr nor bd to run. renderers — two consumers of the same model.
bd discovers the trees. The agent provider filters and enriches them.
An earlier draft had herdr enumerate the roots. That is wrong, and the evidence is concrete: the tracker held a root with no pane in herdr at all. Herdr-first enumeration drops it silently.
herdr is one adapter behind the seam. Anything that can answer which panes are
alive, in which directory, showing what, and can bring one to the front, is
another; collect::agents is what a second one implements, and collect::herdr
is the only module that spells herdr's own argv.
So bdi runs in two tiers:
| bd only | + an agent provider | |
|---|---|---|
| tree of work, correctly drawn | ✅ | ✅ |
| done / left / in-flight counts | ✅ | ✅ |
| who claimed it, and when | ✅ | ✅ |
| configured badges | ✅ | ✅ |
| stale claim | by age — a heuristic | exact |
| agent alive right now | ✗ | ✅ |
stale-pane and unattributed |
✗ | ✅ |
| pane tail and focus | ✗ | ✅ |
The bd-only tier has two states, and only one of them is a finding. A provider that is configured and stops answering is something the reader had and lost, so it is said at the foot. A machine with no provider installed is the ordinary state of a reader with a tracker and nothing else: every tree draws, the foot says nothing, and the tail band says there is no provider — once, in the one place a run has to write something anyway. Degrade, never disappear is about something that broke; nothing here has.
Which of the two a run is in is inferred rather than configured, and the line
is not whether the provider ran. A provider on PATH without an execute bit
never ran either, and neither did one whose directory is not there; both are
something the reader has, and both used to take the silence reserved for a
machine that never had one. The line is whether anything is installed to run at
all: that is the one failure that is not a finding, and every other failure —
including a provider that is there and will not start — is one the foot says.
Nothing but the kernel can draw that line, and it does not quite: ENOENT is
what it answers both to a name nothing on PATH holds and to a working
directory that is not there. So the directory is asked after, on the failure
path, and settles it. Both missing at once reads as the directory, because
until the directory is there nothing can be learnt about what is installed to
run in it.
The age heuristic earns its keep alone. In one tracker, two beads have sat
in_progress for 54 and 58 days. No herdr needed to see that. It stays a
warning rather than a verdict, because a genuinely long-running bead trips it.
When herdr is available, the default view is trees with at least one live agent — the work actually in flight. Trees with no agent are not dropped; a project's collapse to one line under it, so nothing disappears silently and everything beneath a project is still under its one node:
├─▸ 4 trees with no live agent
a toggles to the unfiltered set, and the choice is the reader's: it survives
a refresh, as the folds and the cursor do. When herdr is unavailable there is
no filter to apply, so every discovered tree renders, and a notice at the foot
of the screen says that which agents are alive is unknown.
Roots come from bd, unioned and deduped:
-
Every unfinished bead —
open,in_progress,hooked,blockedordeferred, and neverpinned, which bd keeps indefinitely and never counts as work — and every unfinished wisp, walked up itsparentancestors. This uses bd's own statuses and needs no convention, and it is read off thelist --allandquery ephemeral=true --allanswers the forest is drawn from rather than asked of bd as a subset of them: measured 2026-09-02 against this project's tracker, every row--statuslisted was in the--allanswer, and the same for wisps. An earlier draft took onlyin_progressandblocked, which turned a tracker into a handful of roots; sincedd2c3b5the climb starts from every unfinished bead, so every tree with anything left to do is drawn.Where the climb ends is not always the root. The
parentfield and the dependency edges are two relations, and a bead with no parent used to be its own root while an edge also nested it under everything that depends on it — so a chain of n parentless beads drew as n trees, the deepest of them in all n. A climb that ends at a bead with no parent at all therefore defers to the edges: the root is where a tree that draws that bead has to start, which is the bead itself where nothing nests it, and otherwise the top of whatever does. Measured on this project's tracker 2026-09-03, 37 of 73 open beads had no parent, so this is the common wiring rather than an exotic one.A climb that ends because the parent is set and this read cannot follow it — a parent the answer does not hold, or a chain that comes back round — does not defer. That bead has something wrong with it that only its own tree reports, and a reader does not find a lost parent buried under whatever happens to block it. It is a root wherever else it is also drawn.
Two consequences worth stating, because both look like defects cold. A closed bead can be the root of a drawn tree: the answer's edges hold every bead, and a parentless bead placed only by a closed one has to be drawn from that closed bead or from nowhere — which is rule 4's behaviour, reached by a bead that lost nothing. And the bead being worked is no longer near the top of the forest: it sits at its own depth, kept on screen by the fold, which rests every line above something live open. Proximity to the top, if it is wanted, is a rule of its own and not this one.
-
Roots named explicitly in config, or as
bdi <bead-id>arguments. Both carry the project whose tracker holds the bead, because the key is(project, id): config lists the ids under the project, and an argument is written<project>:<bead-id>— bare where there is only one project, which is the whole of a zero-config run.A bead named on the command line is focused, and discovery still runs.
bdi meadow:mdw-123asks to be shown that bead, andbdistarts as if the reader had pressedShift+Fon it. Its tree is read beside the ones the other rules find: a bead below a root is climbed to that root as a pane's bead is, and one the tracker does not hold is named as a root so that its tree reports it missing. -
Any bead named by a live pane's
display_agentthat the first two missed. This is the only root herdr contributes, and it exists so an agent working off-tree still appears. -
Any bead the answer holds that the first three leave no way down to: a dependency that would have nested it names work the tracker no longer holds, and no surviving edge nests it under anything. Such a bead is the top of its own graph. A tree reports the beads it drew, so a bead no tree draws is a bead no tree reports — drawing it is what leaves it somewhere to be reported from.
A run reads some of the configured projects and never the rest. It is not a
view filter: the projects left out are never read. Which projects those are
is a function of the config, the directory bdi was started in, and the
command line, decided as the config is assembled and before git is asked
where each project is worked — which is itself a subprocess in the project's
own directory. The config is not narrowed. It keeps every project it names,
and carries beside them the scope: which of them this run reads, and what
chose them. Every site that gathers reads the projects through that scope —
the collection loop, the working trees git is asked for, the order the trees
are drawn in, the forest drawn before any tracker has answered, the names the
inbound channel answers ok to — so one decision reaches all of them and
none of them needs to know about it. The sites that place a pane read the
projects as written, which is what lets a pane on another desktop be placed
in its own project rather than reported as in a directory nobody configured.
See The excluded projects stay known below.
The directory bdi is started in decides the read set. The use is one
bdi per desktop: a desktop's panes sit under one project's directory, and a
bdi started there should draw that project's trees and nothing else. The
project that holds the current directory is the one read — holds being the
test the join already uses to place a pane: under the project's path or any
of its working trees, deepest match winning, so a repository inside another
resolves to the inner one, and a launch from a repository inside a project's
directory lands on that project. Started outside every configured project,
bdi reads everything: there is nothing to scope to and nothing was asked
for.
One case that test misses is a linked worktree placed outside the project's tree, because the scope is decided before any project has been asked where it is worked. It is covered with one git call from the current directory for the working trees of whatever repository it sits in, and the directory's counterpart in each of them is tried against the same test — each, because a config may name a project by its place in a linked worktree rather than the main one. Nothing runs in any other project's directory.
--all-projects opts out and reads every configured project. It is what
the session watching everything from one project's checkout runs. It cannot
be --all: that flag draws every tree, including those with no live agent,
and keeps that meaning. --all-projects with --project is a command line
asking for every project and for only some, and is refused as the
contradiction it is, the way --poll --no-poll is.
An explicit --project <NAME>, repeated for more than one, outranks the
directory. It stays the way to ask for two projects, or a different one,
from anywhere, and the directory is not consulted. --project has no path
form; the directory is the path form.
The no-config run is unchanged. It discovers the project the current directory sits in and reads that; the rule above is that behaviour with a config present. The one project it discovers is everything there is, so the run reads it as everything and the screen has nothing to say about a scope.
Never reading the rest matters because a collection is most of what a run
costs, and the cost follows the number of projects rather than the size of
any one tracker. A
project whose tracker has moved is read in full: four bd invocations
whatever the tracker holds — ready, blocked, list --all, query ephemeral=true --all — all of them after the environment its tracker is
read in is settled, which is a process of its own only where the project
named a credential command, or is entered by one its config named or its
directory implied. Counted off collect::bd
once discovery read the listing rather than three subsets of it (bdi-9jj.8);
before that it was six, plus one show per closed parent the climb stepped
onto. A project whose
tracker has not moved pays one bd sql probe and none of the rest — see
Reading a tracker only when it has changed. Measured on 2026-09-01 at
268ab2d, before that gate landed: bdi --json, which waits for the whole
collection and draws no screen, took 8.2 seconds against a config naming
three projects, and 3.5 to 4.1 seconds against two at 40f4eb5.
bdi itself no longer makes the reader wait that out — since cfcbd80 the
forest is on the screen in about 22 ms and fills in as trackers answer — so
what scoping buys the reader is not the first frame but every collection after
it, and the whole of --json. Nothing bounds how many projects a config
names.
A scope naming no configured project is refused, and the refusal lists what
is configured. The likely cause is a typo, and the alternative is starting on
an empty forest the reader cannot tell from a quiet one. This is what the file
already does with an unknown project name in [roots.explicit] or in a
<project>:<bead-id> argument.
What decides whether a run is scoped is whether a project holds the directory
or a --project was typed, never how many projects a --project selected. A
scope that selected nothing is refused rather than obeyed, and a run scoped by
neither reads everything — the two must not be reached through the same
emptiness test.
Scoping by --project is silent, and this is a deviation from degrade,
never disappear. That principle governs a tree bdi could not draw: an
unreachable tracker, an orphaned dependency. A project the reader excluded on the
command line is not a failure to report, and a standing line about it would
be noise on every run of a flag whose whole purpose is a smaller screen. The
reader typed the scope; the screen does not need to tell them what they typed.
A scope the directory chose is said on the screen. The reader did not
type this one, which is weaker ground for silence, and a reader who sees one
project could think the others vanished. One line below the groups, in the
shape the hidden-trees group takes — no warning mark, and the way to the rest
where that group keeps its key — names the project being read and says that
--all-projects reads every project. That is the degrade-never-disappear
answer: the projects left out are not drawn, and the screen says so.
The positional <project>:<bead-id> names a root, and does not scope.
The two arguments do different jobs: --project decides which trackers are
read, the positional decides which trees one tracker being read draws.
Merging them would remove the ability to name one project's tree while still
reading everything, which is what the positional does from outside every
configured project.
Scoping is applied before the roots the command line names, and what a
positional under a project the scope left out means depends on which kind of
scope it is. Against a scope the reader typed it is refused. The
contradiction is inside one invocation — the same command line asking for
beta's root and asking not to read beta — and there is no reading of it
under which both halves are meant. The other order accepts it and then draws
nothing: a project's roots are read only inside its own collection, so
a root under a project no collection reaches is dropped with nothing said
about it. Refusing is the degrade-never-disappear answer here rather than the
price of it. Against a scope the directory chose there is no contradiction,
because the reader asked for nothing the root contradicts: the root widens
the read set to take its project in, so bdi meadow:mdw-123 from the dunwich
desktop reads dunwich and meadow. The widening happens before git is asked
where each project is worked, so the project a root brought in learns its
working trees like any other.
A root the config file names under an excluded project is not that
contradiction, and is silent. It is a standing preference the reader is
overriding for one run, so its tree is one the reader excluded rather than one
bdi could not draw — which is the rule that makes scoping silent in the
first place, applied to a root instead of a project. The entry stays in
roots.explicit rather than being pruned, because nothing consults it for a
project no collection reaches.
One consequence worth knowing: a scope that leaves exactly one project makes a
bare bead id unambiguous, because what a bare id was ever ambiguous about is
which of the trackers being read holds it. bdi --project dunwich dun-7 works
against a config naming three, and so does bdi dun-7 from dunwich's
checkout.
Considered and rejected: widening an explicit --project by the projects
the positionals name. It would let bdi --project alpha beta:xyz work by
putting beta in the scope because a bead of beta was named. What sinks it
is that it infers an opt-in the reader cannot see: --project alpha would
read beta, and every project line on the screen looks like one they asked
for, so there is nowhere to notice it. Refusing costs one word to recover
from; reading an excluded tracker is not observable at all. A scope the
directory chose is widened, and the difference is that the reader typed
nothing the widening overrides — and the screen says the directory chose, so
a project line beyond the one named there is visibly one the reader added.
The excluded projects stay known to the run. The config as written stays reachable alongside the read set, rather than the projects being narrowed to the read set and the rest dropped. Two things need it now. The join places a pane by which configured project holds its directory, so a scoped run whose projects were only the read set would report every pane on the other desktops as in a directory no configured project covers. Panes are placed against the config as written, and a pane under an excluded project is neither drawn nor reported: not loose, because it is on another desktop's work; not unconfigured, because the config names its project; and not a claim on a read project's bead of an id it names, because its own tracker was never read and says nothing about that bead. A read bead naming a pane that sits in an excluded project is still reported, as a pane in that project. And reloading the config while running will have to re-derive the read set from the new file, so it is kept a function of config, directory and flags rather than a value computed once at start. A dependency on another project's bead is looked for among the projects the run reads. Where the directory chose the scope and a project it left out states the blocker's prefix, the run widens to read that project, as a root on the command line widens it, and goes on reading it from then on: the reader typed nothing the widening overrides, and the project's line on the screen shows it was added. A project read this way draws none of its own trees. It adds only what a drawn tree reaches in it, which runs from a bead to its children and to the beads blocking it, and never up to a parent or out to what a blocker blocks. A pane in it shows only against a bead so reached, and is otherwise treated like a pane under an excluded project. Nothing else is read to find a bead, so a project nothing drawn needs stays unread. Against a scope the reader typed, a bead only an excluded project holds stays work the answer does not hold, because reading that tracker is the unseen widening rejected above.
Different setups encode different things in bead metadata. bdi hard-codes none
of them. Its config names which keys to notice:
[roots.explicit] # roots named outright, per tracker
dunwich = ["dun-7"]
[[badges]] # rendered as a marker on the row
key = "metadata.delivery_pr"
render = "⇢ {}"
[[badges]]
key = "metadata.blocked_on"
match = "human"
render = "⏸ waiting"Nothing in the model knows what delivery_pr means. It knows a key was
configured, found on a bead, and should be drawn. A setup with different keys —
or none — works the same way.
Verified against a live session, 2026-08-30.
Every call bdi makes to bd is spelled in one place, collect::bd::asked, and
the roster is short: bd list, bd query, bd ready, bd blocked and one
bd sql probe. bd dep tree is not among them. An earlier draft of
this section described a per-root bd dep tree <root> --direction=up --json
walk as the tree source; that call was replaced and its row shape survives
nowhere in bdi (bdi-r95, bdi-7ao.12).
The tree source is one bd list --all --include-gates --limit 0 --json per
project. It
carries every bead the tracker holds, and each row names every bead it depends
on in a dependencies array of depends_on_id and type — the whole graph,
complete, in one call. Timed three times each against this project's own
tracker on 2026-08-30 with bd 1.2.2: 133–204 ms, against 1454–2292 ms for bd dep tree over the largest root. One call per project rather than one per
root, and it is what makes drawing a blocker under every bead it blocks
possible at all: bd dep tree is a spanning tree, not an edge set — bd dedups,
so each bead comes back carrying only the one edge the walk first reached it
by (93 of 176 edges for this tracker's largest epic, same date, same bd). All
three flags are load-bearing. --limit 0 lifts a default of 50 that truncates
visibly; --all lifts a default of open-only that returns a smaller,
correct-looking answer about a different population; --include-gates lifts a
default that leaves out every gate, so a bead blocked by one would be drawn
waiting on nothing.
A row carries:
| field | use |
|---|---|
id, title, status, priority, issue_type |
the row |
parent |
the bead's own parent, as bd holds it — not a traversal's |
dependencies[] — depends_on_id, type |
every edge out of the bead; type is parent-child or blocks, and any other value nests nothing |
metadata |
the whole map, inline |
updated_at, started_at, closed_at, assignee |
the age rules, and the bead window's head |
created_at, created_by, labels |
the head alone. created_by is the row's own field; the row's owner is an address and nothing reads it |
A depends_on_id can name another project's bead. bd stores a bd dep add whose target has a different prefix as an external dependency, and writes
it into the dependent's row as a plain depends_on_id of type blocks. The
bead it names is not among the rows, because this tracker does not hold it.
Measured on 2026-09-24 on bd 1.1.0, README's floor, and on 1.3.0: both write
the edge identically, in bd list and bd ready alike. The project that
holds the bead answers for it in its own call, and Across projects under
Tree construction says how the two answers meet.
Three consequences:
bdibuilds the tree.model::tree::assemblewalks the edges from each root and puts every bead where its edges say it goes, one copy per way down to it. A blocker is drawn beneath the bead it blocks because that walk puts it there. See Tree construction for the rule.- A bead is drawn once per path, and that is the model rather than an
accident.
bd dep treededups;bd listis an edge set, and the tree built from it has as many copies of a bead as there are ways down to it. - Badges need no second call.
metadatais inline per row, so a configured key is read from the row that already loaded.
bd query ephemeral=true --all --limit 0 --json supplies the wisps, the
ephemeral beads bd list never lists. A wisp with no parent would otherwise be
collected and hung nowhere.
bd ready --limit 0 --json supplies readiness, and bd blocked --json the
blocker set. Neither is computable from the rows: a row names what it depends
on, not whether those dependencies are satisfied, and beads already answers
that. bd swarm status shows Ready as a first-class state alongside Completed,
Active and Blocked, so a viewer that collapsed Ready into plain "open" would be
throwing away a distinction beads makes. One call each per project, intersected
with the tree's ids. A bare bd ready leaves gates out as work nobody claims,
so bd ready --type gate --limit 0 --json is asked beside it, and a gate
with no open blocker is ready like any other bead. bd reads no edge to another project's bead, and that is
the one place bdi adds to its answer: see Across projects.
bd list --all --include-gates --limit 0 --json and bd query ephemeral=true --all --limit 0 --json supply discovery: every unfinished row of either is a root
candidate. The climb to a root is answered from the rows already read:
every bd list row carries the bead's own parent, so a closed bead above
open work — the shape discovery never names — costs no further call.
One bd sql --json hashing every table but leases is the probe that gates all
of the above — see Reading a tracker only when it has changed.
What we still redo is the rendering. bd's text tree emits broken glyphs — vertical connectors missing under a node that has following siblings, and child indent that does not line up with its parent's marker. The JSON is sound; only the drawing is not.
A box runs several herdr sessions at once, each its own server with its own
socket, and herdr agent list answers for one of them: the session named on
the command line, else the one the caller's environment names, else the
default. bdi may be run outside herdr, so it takes the sessions from
herdr session list --json — every session on the box, with running saying
which have a server to answer — and asks each running one by name,
herdr --session <name> agent list. Nothing treats the session bdi happens
to sit in as special. A session that will not answer is a finding about that
session, said at the foot and in agents.sessions; the panes of the sessions
that did answer are drawn as they would be had it never existed.
herdr agent list returns JSON over the session socket. The fields that matter:
| field | use |
|---|---|
pane_id |
the pane's id within its session, e.g. wCM:p9 |
cwd |
resolves the pane to a project root |
display_agent |
the bead id an agent stamped |
title |
the agent's one-line "what I am doing" |
state_labels |
per-state text, shown for the state the pane is in |
agent_status |
idle / working / blocked / done |
A session mints its own pane ids from w1 up, so two sessions have held a
w1:p1 at the same instant (measured 2026-09-03, bdi-dd5). A pane is
therefore keyed on (session, id) wherever bdi names one — the join, the
loose panes, the conflicts, the tail — and the listing does not carry the
session, so the collector remembers which session it asked.
herdr --session <session> agent read <pane> gives terminal output for the
tail pane, and herdr --session <session> agent focus <pane> is the only
write bdi makes to herdr, and herdr is not a system of record.
Both name the session, because a read that named none would read whichever
session bdi sits in and draw that session's pane of the same id.
agent_status: blocked means a TTY prompt is waiting — a permission gate, or
a pane still at a startup confirmation. It is a property of the terminal, not of
the work, and it must never be conflated with a bead's status or with any
configured badge. The model keeps them in separate fields.
There is no event stream. herdr agent list prints live state; the collector
polls it once per collection.
Bidirectional, because each direction alone has a hole.
pane → bead is herdr's display_agent, when an agent has set it to a bead
id. Its hole: an agent that has not identified itself carries no bead, so its
work looks unstaffed.
bead → pane is a metadata key naming the pane:
bd update <id> --set-metadata agent_pane=$HERDR_PANE_IDIts hole: a setup that does not write it. And it names the pane by id alone —
$HERDR_PANE_ID is what a seat has, and the session's name is in a pane's
environment only outside the default session — so bdi matches the id
across every session it read. Held by one session, that is the pane. Held by
several, the claim is refused and reported (the last row of the table below)
rather than resolved by picking: nothing the bead wrote says which.
Together they close both. A bead is live if either direction resolves to a
pane present in some session's herdr agent list. The pane id exists from the
moment the pane does, so there is no race against an agent that has not
identified itself yet — which is what makes drift detection exact rather than
a guess.
agent_pane is the default key name and is configurable. bdi never requires
it: without it, liveness falls back to display_agent alone and the affected
rows say so.
Two rules that a naive implementation gets wrong.
A pane joins only to its own project's beads. A pane's cwd resolves it to
a project by the longest matching working tree of that project. A project's
working trees are the place it names, in every working tree git worktree list
reports for its repository — so a project configured as a directory inside the
repository is that directory in each of them, and never the repository around
it. A pane belonging to no configured project joins nothing and lands in
unconfigured; a pane in a configured project that no bead there claims lands
in unattributed. The two are kept apart because only one is the reader's to
fix, and the fix is a config entry rather than a bead.
One worktree per seat is a common way to work, and it puts the panes under
neither each other nor the checkout bdi was run from, so a project that held
only one directory staffed nothing. Without this, two trackers with colliding
id prefixes cross-attach agents — and prefixes are per-tracker and
uncoordinated, so a collision is a matter of time rather than bad luck.
A pane no project holds is placed by where it sits in the main working
tree. A project's working trees are only the ones it was asked for, and a
project a scope left out is never asked — so a pane in one of its linked
worktrees, outside its configured path, is held by nothing and would be
reported as being in a directory no project covers. Where that pane's
directory sits in the main working tree of whatever repository it is in
answers it, and that is read off the files git already wrote: a linked
worktree's .git is a file naming an admin directory under the main
repository's .git/worktrees/, that directory's commondir names the main
.git, and the main working tree is the directory over it. Four file reads
and no subprocess, so nothing runs in a directory that is not bdi's. The
directory the pane is actually in is still tried first, because a config may
name a project by its place in a linked worktree rather than the main one.
Every way that read can fail answers nothing, and the pane is then placed by
its own directory exactly as before: a .git that is a directory, no .git
anywhere above, a .git file with no gitdir: line, an admin directory that
has gone, one with no commondir — a submodule's has none — and a common dir
not named .git, which is a bare repository or a --separate-git-dir one and
the only case where the obvious rule answers a confident wrong path rather
than nothing. Paths are resolved lexically and never through canonicalize,
so a symlinked checkout compares the way the config wrote it.
Folding .. lexically is what makes the answer comparable to a config, and
it is not what the filesystem does: where the pane reaches its worktree
through a symlink, a relative gitdir: folds to somewhere that is not the
worktree's admin directory. Usually that is nothing and the read gives up,
but with two checkouts beside each other it can be a real admin directory
belonging to a repository the pane has nothing to do with — and every check
after it passes, because each is satisfied by any ordinary repository. So the
admin directory is required to name the worktree back, through the gitdir
file git writes inside it. Placing a pane in the wrong repository's working
tree is the one outcome refused outright; saying nothing is always available.
That back-check is asked only where the gitdir: was relative, because only
folding can go astray, and asking it everywhere would refuse a worktree moved
without git worktree repair — whose admin directory still names where it
used to be, while the rest of the chain is sound. It resolves both paths to
compare them, which is not the same as resolving the answer: what it returns
is a yes or a no, and the placement is still the lexical one. That is the
distinction to keep — the rule against resolving is about the answer, not
about what may be looked at to check it.
The rule is not confined to the projects a scope left out. A pane in a linked
worktree of a project this run does read is placed the same way, and
reported as loose in that project rather than as unconfigured: bdi reports
what it can determine, and declining to place a pane whose project it knows
would be the model keeping something back.
The read belongs to app, which annotates each pane as it collects the
listing. model is pure over what it is handed — that is what lets its tests
place panes at paths that exist on no machine — and a placement that asked the
filesystem would answer differently on a machine where one of those paths
happened to exist.
Where the two directions disagree, that is a finding, not a tie to break.
| situation | what bdi does |
|---|---|
agent_pane and display_agent name different panes |
the bead's own key wins; the disagreement is reported |
| several panes name one bead | none wins; reported |
| one pane is named by several beads | none wins; reported, with what the pane says it is working on |
| a pane's project differs from the bead's | no join; reported |
| a pane's directory is under no configured project | no join; reported once as unconfigured, and on each bead whose key named it |
| the bead's key names a pane id that several sessions each hold | none wins; reported with the sessions, on the bead and in conflicts, and every one of those panes is still drawn under its own session |
Silently picking one is the failure mode: each of these is drift of exactly the kind the tool exists to surface, and last-write-wins would hide it behind a plausible-looking row.
A contested pane says what it is working on, in its own words, and bdi
still picks no winner. The conflict carries the pane's caption — its state
label for the state it is in, falling back to its title — and the row reporting
it quotes that caption directly after the pane id and ahead of the roll of
claims, because a sentence too long for the width is cut from the right. It is
the one thing on the screen that can tell the live claim from the stale ones: a
caption is otherwise drawn off the agent a pane was awarded, and a contested
pane is awarded to nobody. bdi reads nothing out of it — no bead id is parsed
from it, nothing is matched against the claims — so the reader decides, which
is what keeps this a finding rather than a tie broken.
What bdi will not do is rank the claims by started_at. The newest claim
reads as the live one only under one assumption about how agents are organised
— a single seat moving between beads and forgetting to clear its key — and it
is wrong for two seats where one died, since nothing says the survivor started
later. started_at stays parsed and unused.
A refused claim carries its refusal. Where a bead's own key named a live
pane the join would not award it — because the pane sits in another project,
or in none, or because several beads name it — the bead's orphan-claim
anomaly carries that conflict as its reason, and the row says it: claimed ·
its pane is in no configured project, claimed · 3 beads name its pane. A
bead whose row said only claimed · no pane sent the reader after a dead agent
that was alive and working two feet away. Only the exact direction can be
refused: a bead that named nothing has no claim to refuse, and a pane naming an
id another tracker happens to reuse says nothing about that tracker's bead.
All computed in the pure model. The first needs bd alone; the rest need herdr.
| rule | condition | reading |
|---|---|---|
stale-claim |
in_progress, not updated in N days |
beads' own bd stale, narrowed to claims |
orphan-claim |
in_progress, no pane resolves for it or for any bead above it in the tree |
the agent died mid-claim — or the bead named a live pane the join refused it, in which case the refusal travels with the rule and the row says which |
stale-pane |
bead is closed, its pane is alive | agent finished and did not exit |
unattributed |
pane alive in a configured project, no bead resolves | a pane nobody can account for |
unconfigured |
pane alive in a directory no [[projects]] entry covers |
a project bdi was never told about; the fix is a config entry |
stale-claim is bd stale restricted to in_progress. Its window defaults to
30 days, matching bd stale --days — not a number of our own. Two names stay
apart deliberately: stale-claim is about a bead nobody has touched;
stale-pane is about a pane that outlived its bead. They share a word because
both are "this outlived its usefulness", and nothing else.
A node carries every anomaly that fires, not the first one. An old claim
whose agent has died is both stale-claim and orphan-claim, and reporting only
the second throws away how long it has been sitting there — which is the part
that tells you whether to care. The field is a list, and it is anomalies: []
on a node nothing fired on — never absent, never null.
orphan-claim keys on in_progress alone. A bead that is status: blocked with
a live pane is not an anomaly — an agent parked on it is a normal state, and
firing on it would report every waiting agent as dead.
A seat that works several beads names its pane on the one covering them all, and
the beads beneath it are in_progress with no pane of their own. So a pane on a
bead covers every bead beneath it, by either nesting edge and at any depth, and
a bead under several beads is covered by a pane on any one of them, in whichever
tree it is drawn. A pane on a
bead says nothing for the beads above it. A bead above whose pane is out of
reach covers too, for the same reason the bead's own would: nothing says the seat
is gone. stale-claim reads bd alone, so a covered claim still goes stale.
A hooked bead is a claim too, and neither orphan-claim nor stale-claim
fires on it. bd stale leaves it out, and a hook can outlive the session of the
agent it belongs to.
orphan-claim has one shape in the JSON whatever its reason: {"rule": "orphan-claim"} for a claim that really did lose its agent, and the same with
a refused field holding the conflict where the join refused the bead's own
key. The field is omitted rather than null when there is none, so the two are
byte-identical up to it. Conflict was already contract surface as the
top-level conflicts array, so nesting one here exposes nothing new.
unattributed will also catch ordinary interactive sessions, which are not
anomalies. It renders as a group of its own below the trees, never as an error
against a tree, and rests open, because what it holds is live. unconfigured
renders as a group of its own above it, naming each directory, because the
sentence a reader needs there is about the configuration rather than the pane.
- Discovery yields the roots.
- Per project, one
bd list --all --include-gates --limit 0 --jsonplus the wisps, and the tree under each root is built from the edges those rows carry. herdr agent list, if reachable, is joined onto the nodes.- The default filter collapses trees with no live agent to a count under their project's line.
What a nesting means. A bead's descendants are the things that must
complete before it can. beads says that with two edge kinds running opposite
ways, and one rule lands them both: a parent cannot finish until its children
do, so a child is drawn under its parent; a bead cannot finish until its
blockers do, so a blocker is drawn under the bead it blocks. An edge kind beads
may add later has no settled direction against completion, so it nests nothing
— the bead still appears wherever its other edges put it. A nesting therefore
never means "the walk reached this through that", and every row carries which
kind of edge put it where it is. Rejected on the way: drawing each bead's
dependencies as its children uniformly, which fixes blocks and inverts
decomposition, so an anchor epic with no dependencies becomes a leaf repeated
under every one of its own descendants; and not nesting blocks at all, which
fixes the count and loses the thing the count is for — seeing, in one place,
everything standing between a bead and done.
The elbow says which. A child hangs on a solid arm, ├── ; a blocker hangs
on a dashed one, ├┄┄ , and shut ├┄▸ . So a bead drawn under its parent and
again under something it blocks is two true statements with two different arms,
not one row twice. The arm belongs to the edge and not to the bead: a child of
a blocker is drawn on a solid arm under the dashed one. A run of closed
siblings is a count, not a bead, and keeps the solid arm whatever it holds. The
prefix stays four columns a level, and the fold marker stays inside the elbow.
No colour carries it, because colour is never the only channel here and the
box-drawing stays in the terminal's own foreground so a reader can follow the
rules down. Sibling order is unchanged — status, priority, id — so blockers and
children interleave, and the arm is the one thing that says which each is.
Terminology is beads' own: parent-child and blocks are the two type
values bd writes on a dependency; nothing is coined.
Why a bead is drawn more than once. A bead has one parent and any number of beads it blocks, so it has as many places as there are ways down to it, and it is drawn at each. That is the normal case rather than an exotic one: 50 of 100 beads drawn more than once, measured against this project's own tracker on 2026-08-30 after a dependency audit (506 rows over those 100 beads, same measurement; the totals move whenever an edge does, and this one moved twice in an hour). A copy is identified by the way down to it — the tree, and the beads stepped through below its root to reach the line — not by the bead, and that is what a fold and a selection are held by. Folding one copy leaves the others as they were.
The tree holds each bead once, and the ways down to it point at it. The
copies are drawn, not stored. A tree is every bead its root reaches, held once
— the root first, then the rest in the order a walk down from it first reaches
them — and, for each, its links: the ways down from it to the beads beneath
it, each saying which bead, by which kind of edge, and whether it is the way
the walk first reached the bead. What the screen draws is that tree unrolled,
a line for a bead at every way down to it whose forebears are open, and the
layout walks the links carrying the way down as it goes. Every question it
asks of a line — what is beneath it, how far along it is, whether it rests
open, what a run stands for, whether it is the first line of its bead — is
answered from the bead and the way down to it: reachability from the bead
with the way down left out, each bead once, or for the first line, whether
every link down the way is a first link. So a question costs the size of the
tree and not of the unrolled shape, which can be very much larger: measured
2026-09-02 against the maintainer's five trackers, 336,063 unrolled rows over
4,611 beads, one tree of 119 beads unrolling to 194,085 of them, and a
keystroke under --all that cost 146 ms over the rows and 5 ms over the tree.
The unrolled shape is walked whole in one place, --json, which writes it.
The facts are answered once per snapshot. Every one of those questions
depends on the snapshot alone — no fold and no selection moves an answer — so
the forest answers them when it takes a snapshot and layout reads them, rather
than asking again for every drawn line on every keystroke. A tree with no loop
in it keeps one answer per bead: nothing beneath a bead can be above it, so
leaving the way down out changes nothing and every copy of the bead reads the
same answer. A tree with a loop cut in it is still asked by the way down,
because two copies of a bead on either side of the cut stand over different
things. Measured 2026-09-02 against the maintainer's five trackers, 6,060
beads held across the shown trees under --all and 3,560 lines drawn: the
keystroke went from 5.0 ms, 3.1 ms of it those questions, to 2.9 ms, 0.8 ms of
it, and answering them once costs 7.7 ms per snapshot.
Dedup is the model's; the copies are the view's. The model holds one node
per bead and the view draws one line per way down to it, and the two are not
in tension. An earlier draft warned that one node per id makes id-based
navigation land on whichever copy was built last; that held only while the
fold and the selection were keyed on the id. Both are keyed on the way down to
a copy, so they land on the copy the reader is standing on, and there is no
last-built copy to lose to. What must not be duplicated is the identity:
(project, id) names one bead however many lines carry it, and asking what a
bead is — which agent is on it, say — resolves the id to one answer, because
the agent belongs to the bead and not to the copy.
What a fraction counts. A line that stands for more than itself says how
much of that is done: a node with children gets closed/total over its whole
subtree with its own bead among the total, and a root gets the same, which
stops the root being a special case at all. A leaf gets a glyph and no count,
because a fraction over one bead only repeats its glyph. Counted as beads, not
rows — every distinct bead beneath the line once, however many ways down reach
it — so it answers "how much of what I am waiting on is done", and a closed
bead cannot report a fraction over beads that merely waited on it, because
those are not beneath it any more. A project's line counts over its trees with
each bead counted once, for the same reason. Keyed on having children, never
on issue_type == "epic": reading a display rule out of bd's taxonomy is
interpreting what a field means, which this project's rules push into config,
whereas having children is the shape of the tree bdi already computes and is
exactly the condition under which the question is askable.
Two degradations, and a cycle. An orphaned dependency is beads naming
something they depend on that no answer holds for them (see Across
projects) — most often a deleted parent; each is
still drawn, and a bead nothing in the answer nests at all is a root of its own
(discovery rule 4), so it is drawn under its project and reported as an
orphaned dependency there rather than nowhere. cycles is beads whose own descendants lead back to
them — a bead blocked by one of its own forebears, which beads permits — each
still drawn, where the loop was cut. The cut is the way down: a walk that
comes back to a bead it came down through stops there, so the tree holds the
way back up as a link like any other and every walk declines to take it.
Which beads are reported is therefore a fact about the walk and not only
about the loop — a bead met above a loop is cut when the loop comes back to
it, and one met only from inside the loop never is — and two copies of a bead
on either side of a cut are the one place two copies stand over different
things. An earlier draft called the second
unreachable and hung such beads off the root; under this rule every bead is
drawn where the rest of its edges put it and it is the loop that is cut, so the
old word would have been false on a contract field. That is a JSON contract
rename (trees[].unreachable → trees[].cycles), free when it was made
because nothing consumed the contract.
Ordering within a level: state first (in-flight, then blocked, then open, then closed), priority second, id third.
A bead waiting on another project's bead holds it, as it would a bead of
its own project. Its row names the other bead by id alone (see Data
sources), so the id is looked for in the bead's own answer first, and only
an id that answer lacks is looked for among the other projects the run
reads. It is another project's bead where exactly one of them holds it. Two
holding it is a prefix collision nothing in the row settles, and one that
only an excluded or unreadable project holds is out of reach; either way it
stays work the answer does not hold, and the bead is reported as an orphaned
dependency.
Only a blocks edge crosses: a blocker hangs beneath the bead waiting on it,
whereas a parent in another project would put this project's bead inside
that project's trees.
A blocker no answer holds is drawn where it would hang, saying why. A
tracker's beads carry its prefix, the id up to its first - as bd reads one,
so the prefix says whose the bead would be. The line names the projects whose
answers carry the prefix and hold no bead by that id, or the projects that
each hold one. Where no answer carries the prefix, it falls to the configured
projects that gave no answer, whether refused, unreachable or left out of the
run, because nothing can learn the prefix of a tracker that did not answer.
Only a project's config can state it. Where one of them states the prefix
and the directory chose the scope, that project is read and the bead drawn,
as The excluded projects stay known to the run says. Where the reader typed
the scope, the line says the bead is in that project, which was not read.
Otherwise it
names the ones that may hold it: those stating the prefix, or failing any,
those stating none. Where no configured project may hold it, the bead is in a
project bdi is not configured to read. A parent no answer holds gets no such
line, because it would have been drawn above the bead rather than beneath it. The tree's
note still counts every bead waiting on something missing, because a fold
can hide the line.
The other project's bead is drawn with everything beneath it, and stays
that project's. The walk carries on through that project's answer, into a
third project where one of its beads waits on one, and a loop across
projects is cut where it comes back round like any other. Each bead is keyed
(project, id) on its own project wherever it is drawn, so its readiness,
badges, agent and anomalies are the ones it has there; a search, a fold and a
reference in the bead window reach it as that project's bead; and a tree
counts two projects' beads of one id as two. The other project still draws
the bead in its own trees.
A bead waiting on another project's unfinished bead is not ready, whatever
bd says. bd records the edge but reads no edge to another project's bead
when it decides what is blocked, so bd ready lists such a bead. bdi reads
both trackers, so it takes bd's readiness and blocker set and adds to them each
blocker in another project that is not finished. The bead is not ready, and
that blocker joins its blocked_by. Finished is bd's own rule for a blocker,
closed or pinned, and a finished bead waits on nothing. Nothing else about
bd's answer is second-guessed, so once the other bead finishes, bd's answer
stands again. The forest, the bead window and the JSON all read this one
answer.
A blocker no answer holds counts where it may be unfinished work. Where
the blocker may be in a project that gave no answer, or several projects hold
a bead by its id and one of those beads is not finished, the bead is not ready,
and blocked_by names the blocker: nothing bdi read can say the bead is
free to start. Where the projects carrying its prefix answered and hold no
such bead, or no configured project carries it, bd's answer stands. bd holds a
dependency on a bead that does not exist as blocking nothing, and no later
read would find one.
Only a tree that needs it pays for it. Each project's read assembles its trees from its own answer. A bead waiting on something that answer does not hold is one its tree already reports as an orphaned dependency, so a collection assembles again, across every answer read, only a tree reporting one, and a run with no such bead reads no answer twice. The drawing reads every project's standing answer, so a refresh of one project redraws what other projects' trees hold of it.
The invariant, stated once: no live agent and no anomaly is hidden by a fold
bdi chose — not by a fold, not by an elision, not by a filter. The
live-agent filter already reasons this way in the other direction, collapsing a
tree with no live agent into hidden_trees; this is its dual. Three
mechanisms each used to break it — an elided run's phrase denying an agent its
count included, quiet tested on a sibling and applied to its whole subtree, and
a default fold that hid agents nobody had navigated to — and each is closed
below. A reader may fold over live work by hand; that is the one place the
rule yields, and it yields because they asked by name.
The default: open the spine to the work a reader needs on the first screen,
and nothing else. A line rests open exactly when something beneath it is a
live agent, an anomaly, or a ready bead. Nothing else opens a fold:
unfinished work that is blocked or deferred does not, because readiness is
bd's own answer, with Across projects' one addition, and not a status test. The bead that earns the fold does not
open its own; only its forebears open, so the screen is that work and the spine
down to it. Not full expansion, which is a wall of closed work; not
collapsed-except-selected, which was never built — the selection has no bearing
on what is open. With the filter on, every tree drawn has an agent, so every
tree opens; with it dropped, a quiet tree is a shut line. The two alternatives
were drawn against the real tracker before Graeme chose: annotating a shut line
alone still costs a keypress to see ready work, and opening to every unfinished
bead put roughly 34 of 77 nodes on screen plus their spines, which is close to
having no fold at all. Measured at 3385907 against 279e71b on this
tracker's 81 nodes: 35 lines at rest before, 39 after.
Which rule opens the spine is the reader's to choose, and s cycles it.
A bead is drawn once for every way down to it — under its parent, and under
each bead it holds up. Every copy opens every one of those ways, so a
blocker two siblings wait on puts its whole subtree on the screen twice.
Under a one-copy rule each bead that is work a reader needs is opened to
down one way only, and every other copy of it rests shut over that same work
and says so, in the words any shut line uses. The invariant is untouched:
nothing is hidden, because a copy resting shut still counts the agents and the
anomalies beneath it and still names them on its row. What changes is how many
times the reader reads the same subtree. There are four such rules, and the
key offers them after every copy in this order. First reached takes the way the walk placed
the bead on, which is the copy the rest of the screen already treats as the
bead's own. Shallowest takes the way of fewest steps, so the bead is drawn
as near the top of the tree as anything reaches it. Parent-child takes the
way the bead's own parent-child edge hangs it on, and falls back to first
reached where no way down from the line the rule was set on reaches its parent.
Deepest takes the longest way down, so the bead is drawn under as much of
what waits on it as the tree can show at once. Shallowest and deepest break a
tie the same way, through the parent placed first. The forest starts under
deepest, the one Graeme chose to earn its keep: once agents have wired
dependencies between an epic's children, shallowest and parent-child both stop
at that epic's first level, where the other two go on down to the work, and
deepest draws the work under everything waiting on it. s from there comes
round to every copy next and on through the rest. A tree with a loop in it is drawn
under every rule — a link back onto the way down is skipped, exactly as every
other walk of the forest skips it, so a loop costs a degraded choice rather
than no screen.
s puts the next rule in force under the selected node and S across the
whole forest, and neither touches a fold the reader set by hand: the rule
decides what a node rests as, and a hand fold stands over the rule as it
stands over the default. The rule in force at the selection is named at the
foot, where a rule the reader put in force accounts for the screen it gave
them; the rule the forest starts under is named nowhere, because it is the
screen a reader opens bdi on and pressed nothing to get.
A fold set by hand stands over what it folded away, and is spent when a refresh brings something live beneath it that was not there before. So a hand-fold survives any number of refreshes while the work under it is unchanged or dying down, and an agent arriving on a bead the reader never saw hands the node back to the default. A fold means "I have seen this and do not want it", which stops being true the moment something new is under it. Only hand-folds are stored; the default is derived, which is what makes "back to the default" a single key.
Shift+F roots the forest at one bead, and that is the other place a reader
asks by name. The key draws the selected bead where a root is drawn, what the
forest draws beneath that bead anywhere else, and nothing else: no other root
and no other project's tree. It is for finishing one bead, so what it takes
away is everything a reader does not have to do in order to close the one they
are on. Everything beneath that bead is already in the tree it is drawn in, so
this is a change to what the layout walks rather than to what was collected,
and the bead keeps the place it has everywhere else — which is what lets a fold
set on it survive the key both ways. The fold rule begins afresh at that bead,
so rooting at a later copy of it draws what rooting at the first copy does. The
default above rejects
collapsed-except-selected and the rejection stands: the selection has no
bearing on what is open, and it has none here either, because the mode stands
on the bead named at the keystroke and moving about under it moves nothing.
Pressing the key again puts the forest back, with the selection on the bead it
was rooted at, opening whatever has been shut over that bead in the meantime.
The command line roots the forest the same way: bdi <bead-id> starts as if
the key had been pressed on that bead, once a collection draws it. Several
named are each drawn as a root, which is the one way the mode holds more than
one bead; a bead named beneath another is already drawn under it. The key
then puts the forest back as it does after any other focus. The bead leaving the collection is the one thing that ends the
mode on its own, and a bead leaves only when its tracker answers and the answer
does not hold it. A read that did not answer has not said the bead is gone, so
the mode holds through it, and a line where the bead's row would be says why
there is none. A bead the command line named never lets go: the view was
started to show it, so when it leaves the view stays where it was and that line
says the bead is gone. A bead that closes is still in the collection, so
closing the focused bead does not end the mode, and a bead the tracker has
moved is followed to where it moved to. Everything the mode stops drawing goes behind
one collapsed line per project rather than off the screen, which is degrade,
never disappear binding here as everywhere: every other root and every other
project's, and the focused bead's own root for the part of it that is left —
the beads above that bead and every branch off them, with the bead itself left
to the root of the forest rather than drawn a second time. That line says how
many trees it stands over, in the noun the filter's line uses, because a root
whose tracker refused holds no beads and is still a tree. It stands over open work
with seats on it, which the filter's own line never does, so it also says how many
seats and how many beads want looking at and never claims there are none. Those
it counts off the beads it is standing over rather than off the roots they came from,
because the beads on the screen are not behind it. Its
own project's line keeps it, so a reader opens the project where it already
was. It is a group like the others: it rests shut, the keys that open a group
open it, and the roots inside it rest shut as the filter's do. A bead in one of
them is reached by the keys that reach any bead, the line opening to let the
selection in, so looking a bead up in another root is not paid for with the
put-back key. A search offers what it can take the reader to, which under this
mode is every bead the forest holds: the ones beneath the focused bead, and the
ones behind the line, which it opens on the way. The key that brings them
back acts on the whole screen, so the line does not name it: while the forest is
focused the foot offers F among its keys and says the forest is focused, once
however many projects keep such a line, and both go when it is put back.
A finished branch draws as one line and rests shut. Its glyph, its fraction and its fold marker already say finished, and holds more; opening it is the ordinary fold. A closed bead standing over unfinished work is the ordinary shape of a tree walked by dependents — closing a blocker is how work proceeds — and it is not finished: it collapses to one line and says how much it holds, 3 unfinished beads beneath this, unless some of that work is ready, in which case it rests open. A closed branch is drawn open, in spite of all this, when a live agent or an anomaly sits anywhere inside it. A shut line over agents or anomalies says so beside its fraction — ◍ 2 agents beneath, ⚠ 1 bead beneath — counted as beads, and strictly beneath, because the line's own agent is already on the row by name.
A run of finished siblings is drawn as a count where there are enough of
them. Three or more finished siblings collapse to one line, ✓ 13 more beads · finished, and nobody on them, and count them and their whole subtrees.
Finished is closed or pinned: bd leaves a pinned bead out of bd ready,
bd blocked and its default list, and never lets one block its dependents. Because its
members are finished branches, its phrase is true of every bead it counts, not
merely of the siblings it names. Fewer than three are drawn: … 2 more costs a
line and saves one. The threshold was chosen against this tracker's shape
rather than Graeme's word (his word was many): seven nodes had one finished
sibling and three had six or more, so it decided exactly one node. A closed
sibling carrying an agent or an anomaly is never in a run — that is the
stale-pane case, and eliding it would hide a live agent.
A run is a fold like any other. It is selectable, opens with the keys that
open a bead's children and shuts with the ones that shut them, and its fold
survives a refresh. Opened, its beads hang under the run line rather than
splicing back into the parent's sequence: a run is always the last of its
parent's entries, so a sibling drawn after it at that depth would follow an
elbow that had already said it was the last, and nesting keeps the box-drawing
well-formed and lets h and l step out of and into the run through the depth
arithmetic that was already there. An open run re-elides inside itself: the
three-or-more rule is a property of the forest and not of a place in it, so a
sibling whose own children are a finished run draws a nested count, and the
account still reconciles because the count is recursive. Drawing flat instead
would mean opening … 143 more dumps 143 lines that can only be folded back by
shutting the whole run. The marker convention holds here as everywhere: a shut
run says so in its elbow, └─▸ ✓ 2 more beads …, and an open one says it by
drawing its beads beneath.
The cursor rests on anything with an identity that survives a refresh: a bead's row, a root that would not read, a project line, a run, a group line and each thing inside a group. A note — a per-tree finding drawn under a project's roots — stands for a finding rather than a thing, has no such identity, and the cursor steps over it. That is the same question as whether the selection can be put back after a refresh, which is why the two have one answer.
Where several trackers live on one Dolt server, bd uses one database and one
user per project, with the password supplied per project through the
environment. So a process holding one project's credential cannot read another's:
$ bd -C ../other-project list
Error: failed to open database: ... Error 1045 (28000): Access denied for user 'other-project'bd found the config — it knew the database and the user — and had no password for it. This is a credential boundary, not a policy one, and it is the biggest constraint on the multi-project view.
Confirmed with the operator of this deployment: no cross-project reader exists today; the one read-only user on the server is scoped to a single database.
A project's config entry says how much of its environment bdi has to
reproduce, and the rungs compose rather than excluding one another:
[[projects]] says |
the tracker is read in |
|---|---|
nothing, in a directory with an .envrc, on a machine with direnv |
what direnv exec . produces, detected rather than asked for |
| nothing, otherwise | the ambient environment, with the credential the launching shell holds |
credential_command = "…" |
the environment above, with the command's stdout as the credential |
environment_command = "…" |
what that command produces, bdi appending its own env -0 to read it back |
| both commands | what the environment command produces, with the credential command run inside it and its stdout replacing the password |
The environment command is a wrapper the reader would type themselves —
direnv exec ., nix develop -c, mise exec -- — and it runs in the
project's own directory, which is what lets it be written relative. bdi
knows none of those mechanisms by name: each is reached by a config naming it
and by no code here, which is why an old project pinned to an older bd is
expressible at all.
It is run directly rather than through a shell, so an absent wrapper is a
spawn that failed and the reader is told which program to install. Through
sh -c the same machine is told the shell exited 127 for a reason bdi
cannot place, because "command not found" matches none of the stderr phrases
collect/run.rs classifies by. That is what decides the shape of the setting:
a wrapper is a program and its arguments rather than a shell line, so it is a
line split on whitespace with no quoting honoured, and an argument holding a
space is written as a list — ["nix", "develop", ".#dev shell", "-c"]. The
alternative was a quoting rule every reader learns for a space almost none of
them has, and a config that names one argv while bdi runs another.
A directory that says how it is entered is entered, and the config says
nothing. Where a project's directory holds an .envrc and the machine holds a
direnv, the tracker is read with what direnv exec . produces. That is the
rung that costs a reader nothing, and it is what makes the common case
configure nothing at all.
Both halves are checked and they answer different questions. The .envrc is
the project saying how it is entered; the direnv is the machine saying it can.
This is not the assumption that direnv is there — a machine without one reads
every project ambient, which is also what a person's own shell gives them in
that directory, so nothing has been given up. It is also what keeps a
detection that could not have worked from failing a project: bdi acts on the
inference only where the inference is known to be available, and a detection
that fires and then cannot produce an environment is a project that could not
be read rather than a quiet return to ambient. The .envrc is asked first,
because it is the selective question — a machine with direnv has it for every
project alike.
direnv is the one mechanism detected, because an .envrc is a file bdi can
see and the others are not. nix and mise are entered by a command a person
types, and a flake.nix says a directory has a shell rather than that
entering it is how this project's tracker is reached. Those stay named in
config, which is the rung above, and a config that names one wins over what
the directory implies.
Ambient is what a machine with bd and nothing else gets, and nothing is
run to find that out. -C naming the tracker outright is what makes it safe,
and the rest of this section says why. direnv was the unconditional default
before -C, when entering the directory was the only safe way to reach the
right tracker, and a machine without direnv then read no tracker at all; it is
now one of several ways a setup with one credential per project supplies them,
and that is a setup rather than the tool, so a mechanism bdi cannot see is
named by the command that runs it rather than by a word bdi holds a list of.
Neither a credential nor a tracker path is carried by a working directory.
An earlier draft said bd finds a project's credential by being run in that
project's directory. It does not. BEADS_DOLT_PASSWORD reaches an interactive
shell through direnv, and a child process inherits the parent's environment
whatever its working directory is. So a single process that merely changes
directory authenticates every tracker with whichever credential it started
with — silently, and against the wrong database only when two trackers share a
name.
The same sentence is true of the tracker path, and the first fix missed it.
BEADS_DIR names the tracker and outranks the working directory, so a bdi
launched from a shell scoped to one project read that project's tracker for
every project it was configured with. Which tracker is read and what
authenticates to it are one identity: a child is told both or neither.
A shell that has entered a project's directory is correctly configured for
its tracker. direnv is what makes that true — it loads the flake, the bd
version, BEADS_DIR, and whatever holds the password. So a project entered
with direnv is read by reproducing entering the directory rather than by
reconstructing what entering it would have produced, and the entry says
nothing about what the secret is called or where it lives — nor, where the
directory holds an .envrc, about direnv:
[[projects]]
name = "summit-works"
path = "/tmp/bdi-ground/summit-works"A project entered another way names the command, and that is the whole of the difference between the two rungs:
[[projects]]
name = "dunwich"
path = "/srv/work/dunwich"
environment_command = "nix develop -c"-
The tracker is named outright, with bd's own
-C. Every callbdimakes isbd -C <project.path> --readonly ….-CoutranksBEADS_DIRin both directions, measured:BEADS_DIR=/nonsense bd -C <project>resolves the project, andBEADS_DIR=<valid> bd -C /tmprefuses with no beads project found. Stating the tracker does not depend onbdihaving thought of every variable bd reads. -
-Cis what makes the ambient environment safe, and entering the directory with it. Where direnv fails open — it exits 0 and runs with the ambient environment when a flake will not evaluate — the tracker is named outright, so neither the ambient environment nor such a fallback can point bd at the wrong database. It can only fail to authenticate against the right one, whichbdireports per project asauthwhile every other tree still draws. -
direnv fails open on one of the two ways a directory resists entering, and closed on the commoner one. Measured 2026-09-04 with direnv 2.37.1 (it is direnv's behaviour that decides this table, not any
bdicommit):the directory exit stdout no .envrcat all0 the full environment, unloading the caller's own .envrcallowed, flake will not evaluate0 the ambient environment .envrcunallowed1 empty An unallowed
.envrcisdirenv: error <path>/.envrc is blockedon stderr and nothing on stdout, sobdigets a failure and the project degrades visibly with no fallback to catch. That is the commoner of the two — every fresh clone and every new worktree starts unallowed — and it is why the bullet above is about the flake case rather than about both. Only that case needs-Cbehind it, and it announces itself on stderr asnix-direnv: Evaluating current devShell failed. Falling back to previous environment!The unallowed row is the one detection meets, because detection fires on exactly the directories that have an
.envrc— so the project a reader has configured nothing for is the project that reports the failure, and asked for an environment bdi could not produce is the sentence it gets. Every fresh clone and every new worktree starts unallowed, so this is a sentence readers will meet often and one they can act on:direnv allow.The first row is why a configured
direnv exec .costs nothing where there is nothing to do: a directory with no.envrcis a pass-through, not a failure, and it unloads whatever direnv environment the caller was carrying — which is what a person'scdinto that directory does. Detection does not rest on it, and reaches the same answer for less: it looks for the.envrcand runs nothing where there is none, so the pass-through is a propertybdino longer needs rather than one it relies on. -
Every command line
bdispells is a read except where a pseudopod writes, and that is a property of the subcommandscollect/composes and of nothing beside them. The first pseudopod isbdi bd'sbd human respond, which records a person's answer in the tracker of the project it names, without--readonly. The second is settling a gh:pr gate:bd gate resolveon a merge, andbd comments addon the beads it holds back on a close without one, on failing checks, on a review, on a conflict or on a comment. That the rest are reads is not a no-writes rule. bd writes on its own account on the way to answering, so no property of the command line can exclude it, and Reading a tracker is not leaving it alone below says what it does.--readonlystill earns its place on the line: it vetoes bd's mutating subcommands, so a mutating call arriving incollect/later is refused rather than run — a guard on the next edit, and a veto over subcommands rather than a property of the tracker's files. -
The environment is captured once per project, not per call.
direnv execreloads the directory every time it runs, andbdimakes seven or more bd calls per project when a tracker has moved, so per-call was never affordable. Measured 2026-09-04 with direnv 2.37.1 against a worktree of this repository (it is direnv's behaviour and a project's own.envrcthat decide these figures, not anybdicommit): 136 to 177 milliseconds over thirteen consecutive runs, each reportingnix-direnv: Using cached dev shell, and 1557ms on the first load after the.envrcwas allowed. A project with no.envrccosts 3 to 5ms where a config names direnv anyway, because there is nothing to load; detection does not spend even that, because it looks for the.envrcfirst and finds none.An earlier reading of 1.3 to 2.4 seconds stood here, and this repository was the reason rather than direnv: its
.envrcnested a seconduse flake, so each load evicted the profile the previous one had written and the cache never held. Fixing that took the same call to 137ms on a worktree, and the figure above is what it costs now. A project whose.envrcdoes expensive work per load still costs what that work costs, and the cold figure is what any project pays once after nix-direnv invalidates on an mtime. -
A capture direnv made is kept between runs, while direnv says it is current. Once a run is affordable, a one-shot read run in a loop pays the capture every time, and it was the largest cost left inside
bdi. So the capture is kept on disk, readable by its user alone, because it can hold the tracker's password. A later run uses it only where it was captured under the same environmentbdinow hands its children. Thendirenv export json, handed the kept environment, brings it up to date. It is what a shell's prompt hook runs. It prints nothing where nothing direnv watches has moved, and otherwise reloads the directory and prints what to set and unset, whichbdiapplies as the shell would. direnv's watches cover the.envrc, its allow record, and whatever the.envrcwatches, which is how an editeddotenvfile or a moved flake lock is noticed. Onlydirenv exec .is kept. Nothing else can say when what it produced stopped being current, and direnv answers about the directory it is asked in, so a command entering any other would be checked against the wrong one. Anything that cannot be trusted is captured afresh. The credential command is never kept and answers on every run.So a read costs no load while nothing has moved, and one load when something has, the same as before. nix-direnv 3.2.0 refreshes the timestamps on its profile links on every load, to keep them from garbage collection, and those links are among direnv's watches. So any process that loads the directory moves it on, and the next read pays that one load. Applied to the kept environment, direnv's answer was measured on 2026-10-04 with direnv 2.37.1 to give exactly what a fresh capture gives. That held after an edited
dotenvfile, a moved watched file, a deleteddotenvfile, and an.envrcthat stopped setting the tracker. -
A project whose
.envrcwrites to stdout cannot corrupt an answer. direnv's own log lines reach stderr, measured, but nothing stops a project's.envrcprinting to stdout and only this repository's has been fixed not to. Capturing once confines that text to the one call whose parser tolerates it, rather than to every JSON answer bd gives. -
A directory that cannot be entered fails that project, visibly, and in the project's own words.
bdiadds no fallback to the ambient environment of its own, for two reasons that arrive from opposite directions.The first is that a mechanism that silently does nothing is indistinguishable from one that worked. Where direnv has already fallen back for itself, on the flake case above, that is what
-Cis behind.The second is Reading a tracker is not leaving it alone below, and it is the one that settles it. Falling back would read that project's tracker with the bd on
bdi's ownPATH— which is not the bd the project asked to be read with — and a bd older than 1.3.0 rewrites.beads/.local_versionand runs its schema auto-migration on finding itself newer than the bd that last opened a tracker,--readonlyor not. The decision not to gate on a bd version is taken there on the narrow ground that a tracker may be read by any version of its own project's bd, and a fallback is precisely the case that ground excludes: it reads a project that namednix develop -c, or implieddirenv exec ., with a bd that is not its own. A tracker cannot be put back and the migration announces itself nowhere; a sentence on the screen can be read and acted on.So the project is reported as
no-environment— asked for an environment bdi could not produce · nothing was read, because the bd here is not the one this project asked for. It is the one failure here that is not about bd, because it is the one where no bd ran, and every other sentence would send the reader to a program that was never asked anything. It names no program: a project asks two ways, and only one of them has a program the reader wrote down.Its credential command does not run either. A project with no environment has nothing to be read, so running an arbitrary command a config named would be a side effect spent on a read that is not going to happen — and the tools such a command needs are the ones its own directory supplies, which is the thing that just could not be reached.
A credential command that will not run is the same shape of failure, and is reported as
no-credential— the credential command this project names would not run · nothing was read, and no bd was asked for this project. Opening is those two steps and bd is reached after both, so neither can be reported as bd's. It carries no kind either, and here that is what keeps the screen honest rather than merely uncluttered: the commonest failure is a helpershcannot find, which exits 127 with stderr matching no phrase list and so arrives asunavailable— the tracker did not answer, about a tracker nothing had spoken to. A command whose own words happen to matchREFUSALarrives asauthand claims a credential was refused that was never offered. It names the setting rather than the program:sh -cisbdi's choice andcredential_commandis the reader's, and theirs is the one they can edit.A machine with no direnv is not this case and is not a fallback either. It is a directory
bdinever tried to enter, because the check that decides whether to try is what the absent direnv answered — so nothing was attempted and nothing silently did nothing. The rule the three share is thatbdiacts on an inference only where the inference is known to be available, and reports every attempt that was made and failed. -
credential_commandis the rung below the environment command, for a setup whose only exotic need is the password. The config stores a command, never a secret; its stdout is the password, captured rather than passed in an argv wherepswould show it, which is why it is not folded into the environment command. What went is its promotion to the default, and the rule that demanded one from every project once a second was named. Naming both was refused while the environment was a mechanism, because a credential answering instead of direnv or after it was a precedence nothing on the screen said; a command raises no such question, so the two now compose and the password is whatever the credential command last wrote. It runs inside the captured environment, so a helper only the project's own directory installs is on its path — lessNEVER_INHERITED, because the runner strips those from what a child inherits and then applies what it is handed, and this is the one call whose environment would otherwise carry the very password it is being asked to produce. -
An authentication failure is distinguished from the others.
TrackerState::Unreachablecarries a reason:no-environment,no-credential,auth,unavailable,not-installed,unstartable,installed-unstartable,parse, orunknown-flag. The first two are the two ways opening fails, and neither has reached bd; the rest are bd. They want different responses and reporting them as one string does not help anyone. The middle three are the ways bd never ran, and they are three different things to do about it: install bd, repair the bd or the project directory that is already there, or go and find out which of those it is. The third answer exists because the other two each make a claim about the machine and the kernel's refusal does not always earn either.bdiasks whether anything is there under bd's name on every refused spawn, and onePATHentry nothing may search refuses that question on the same permission it refused the spawn on — so a machine with no bd at all can failEACCES, and neither installed nor not installed may be said of it.unstartableis that answer, and it is also the one anything unable to tell the three apart falls to, so what it claims stays true either way. The last is bd refusing the command line before it runs, in cobra's words (unknown flag,unknown shorthand flag,unknown command): a bd older than a flagbdiuses, which is what a bd below README's floor looks like, and the screen names the floor. Only bd's own refusal counts: a credential command or direnv saying the same words to a flag it lacks is a configured command that failed, as before, and not a bd to replace.bdinever asksbd --version: bd already says which flag it lacks on the first call, a version gate cannot see a newer bd that drops a flag, and the refusal needs no parsing where a version string would. -
No error text reaches the output verbatim. bd's failures name the database and user; the reason is reported, the raw stderr is not.
-
parseis the one reason that says more than itself, and what bounds it is where its words come from. A command whose output would not parse is a command that succeeded, so there was no stderr for the classification to read and nothing bd wrote about a credential to redact. What it carries is the readbdiasked for and the parser's account ofbdi's own structs — a shape that did not match, and where in the answer it was. Those two are what let a reader run the read by hand and land on the row that broke it, which is the whole of what anyone can do about an answer that will not parse. The reason alone sends them to a tracker with five reads in it and no way to tell which.
An earlier draft rejected direnv exec on two guesses, and both were wrong:
that it costs a direnv evaluation per call, and that it requires every tracker
to be a direnv-managed checkout. The first is answered by capturing once; on
the second, a directory with no .envrc runs anyway. Rejecting it as the
default was right for a third reason neither guess named: a machine without
direnv got exec on its first run and drew nothing, when README had said bd
was all it needed. That reason is what detection answers rather than
overrules — the machine is asked whether it has a direnv before one is run, so
the case that drew nothing now reads the tracker ambient.
A single read-only user across every tracker would retire credential_command
entirely. The shape it would take has been measured: GRANT SELECT ON <db>.*
per tracker reaches the base tables and the ready_issues view, and is refused
every write. Graeme decided against it on 2026-09-18, so credential_command
per project stays the design.
bd does work on its own account when it opens a tracker, and which subcommand
asked is no part of it. The work happens before the subcommand runs at all: a
bd sql refused with 'bd sql' is not yet supported in embedded mode, exit 1,
had already done its share.
What that work is turns on --readonly, which every command line bdi spells
against a tracker carries. bd records the version that last opened a tracker in
.beads/.local_version, a plain gitignored file. Without the flag, a bd that
finds a different version there rewrites the file, and runs its schema
auto-migration on a store whose schema is behind it. With the flag it does
neither: it opens the store read-only, and refuses a store whose schema is
behind it rather than migrate it.
Measured 2026-09-24 on bd 1.3.0, the version the flake pins, against throwaway
embedded stores in a temporary directory and never against a live tracker. The
environment held only PATH and a throwaway HOME, so that -C alone
resolved the tracker. It is bd's behaviour that decides every figure below, not
any bdi commit, so the event that dates this table is a bd upgrade and nothing
in this repository. The reads are bdi's own list, query, ready and
blocked calls, each under --readonly:
| what was asked | .beads/.local_version |
the schema |
|---|---|---|
| the reads, on a store 1.3.0 built | untouched | untouched |
the reads, on a store 1.3.0 built, .local_version seeded to 1.2.2 or to 1.9.9 |
untouched | untouched |
| the reads, on a store 1.2.2 built | untouched | refused at v53, exit 1 |
list --json without --readonly, .local_version seeded to 1.2.2 or to 1.9.9 |
rewritten to 1.3.0 |
already current |
list --json without --readonly, on a store 1.2.2 built |
rewritten to 1.3.0 |
migrated v53 → v66, exit 0 |
bd --version, bd version, bd where --json |
untouched | untouched |
A refused read says so on stderr, with stdout empty:
Error: failed to open database: schema version mismatch: database is at v53, binary expects v66, and the read-only open cannot migrate it; run any bd write command in that workspace to migrate, or set BD_IGNORE_SCHEMA_SKEW=1 to read anyway (queries touching newer schema may fail)
bdi sets no such variable. bd sql is refused on an embedded store, so on
a throwaway the hash probe measures no further than its refusal. On a
store whose schema is behind, that refusal is the schema one above, which comes
first.
With the versions equal, the reads left the tracker as they found it: every
file in the project bar .git, sha256 each, byte-identical over two passes of
every read. Two things sit outside that.
Opening the store leaves two lock files. A read that opens the store,
whether it answers or is refused, creates .beads.gate.lock beside .beads/
in the project root and .beads/embeddeddolt.gate.lock inside it. Both are
empty, and a later read creates them again if they are deleted. bd --version,
bd version and bd where --json create neither. The .gitignore that bd
1.3.0's init writes covers both with *.gate.lock*. A project whose
.gitignore lacks that pattern shows .beads.gate.lock as untracked in
git status from the first read on.
Every call queues a usage event on the reader's machine. Each read, and
bd where --json with them, writes one file under ~/.beads/eventsData, bd's
queue of anonymous usage metrics. That is the reader's home rather than the
tracker, and after bd metrics off the same calls queued nothing.
Three things follow, and what bdi claims is built on all three.
A bdi read on the pinned bd migrates nothing. A tracker whose schema is
behind is refused until something else migrates it, and it degrades in the
meantime like any tracker whose read fails.
The migration is still silent and still one-shot, but it is no longer
bdi's to spend. What migrated the store was the next bd run without
--readonly: a plain list --json, exit 0, stdout parsing, nothing on stderr.
It leaves the tracker at the new version, so every read after it is quiet, and
damage a migration did is invisible from the moment after it happened.
A bd older than 1.3.0 still migrates under --readonly. bd 1.2.2, reading
with --readonly a throwaway store that bd 1.0.0 built, took it from schema
v23 to v53 and rewrote .beads/.local_version, exit 0, with nothing on stderr.
bdi links no bd and reads each tracker with whatever that project's
environment resolves, a per-machine fact this project does not constrain. So a
project whose own bd predates 1.3.0 has its tracker migrated by the first bdi
read after that bd is upgraded, and one run reaches every tracker at once.
bdi does not gate on a bd version, and that is a decision rather than an
oversight. The gate is a plain file, so bdi could read
.beads/.local_version against the bd its environment resolves and refuse a
tracker that bd would migrate, without opening anything. That guard was
weighed on 2026-09-04 and declined, on a narrow ground: a tracker may be read
by any version of its own project's bd. Which bd reads a project's tracker is
what entering that project's directory yields — by the command its config
names, or by the direnv exec . its own .envrc implies — and a project
neither names one for nor implies one is read with whatever the shell bdi
was launched from resolves.
So a bd older than 1.3.0 is a known hazard rather than an unnoticed one, and
reopening it means changing that decision rather than measuring it again.
That ground is also why a project bdi could not enter is refused rather
than read ambient. The ground holds only while every tracker is read by its
own project's bd, and a fallback to bdi's own environment is the one thing
that breaks it: a project that named a wrapper and could not get it would then
be read by a bd it did not ask for, unattended, on every refresh, on exactly
the projects a reader configured to avoid that. So refusing such a project is
what keeps the ground under the decision above, and it is why
TrackerFailure::NoEnvironment is a failure rather than a notice over rows
read anyway.
Reopening the fallback and reopening the gate are therefore one question. The
gate is the shape that gives up least — bdi could fall back wherever reading
.beads/.local_version said no migration would fire — and what is unmeasured
there is whether that file exists for a server-backed tracker at all. The
measurements above were taken against throwaway embedded stores, and this one
cannot be taken against a live tracker.
So bdi claims what it can hold: every command line it spells is a read, bar
the writes its two pseudopods make. It
does not claim a tracker comes back
unchanged. The pinned bd leaves its lock
files behind even on a read it answers, and a bd older than 1.3.0 migrates
whatever the command line says. Nothing holds a tracker still, either: the
trigger compares the bd running against the bd that ran last, so an in-place
upgrade of a single bd arms it as surely as a second version would. What one bd
per tracker buys is that the tracker moves forward once, at an upgrade somebody
chose — the operator's arrangement rather than bdi's guarantee.
A root that cannot be read must degrade, not disappear: it renders where its row would have been, named and marked with the reason it would not read, and the live panes still working in its project render under that project's own line, as they do under any project's. A root shown without its beads beats a root silently missing — the same principle as the default filter.
Each tracker sets its own id prefix and no one coordinates them, so two trackers
can collide. bdi reads several trackers in one process, which makes this its
problem in a way it is not for a single-tracker tool. The key is (project,
id), never id alone.
Three things ask for a project to be read again, and they are one mechanism:
the poll a project arms for itself, a message on the inbound channel naming
it, and ^R, which names every project. Each takes the same window and the
same queue; none has a path of its own, so nothing one of them does can be
lost where the others are kept. A collection runs on a worker thread behind a
channel, never on the loop's — the loop waits on events, one of which happens
to be "the timer fired", and a slow collection still lets the reader scroll.
herdr is polled and stays polled: it has no notification source, it is a local
process, and one herdr agent list is nothing beside a project's bd traffic.
The poll. A project asks to be read again refresh_seconds after its last
read finished, whichever of the three asked for that read. The default is
30 seconds, and it governs the fallback timer only: agent and bead state moves
on the order of minutes, ^R covers impatience, and the two mechanisms below
remove most of what a poll costs. Because each project's next read is timed
from its own last one, projects drift apart rather than all paying the cascade
on the same tick, and a slow project delays only itself. Setting it below a
collection is allowed and bounded, because the gap does not start until the
read ends.
A read that held a bead back asks again at the bead's defer_until instead,
where that is sooner than the interval. bd ready starts naming the bead then
with nothing written, so no fingerprint moves and no producer reports it. This
applies to a project that does not poll as well.
Almost every poll finds nothing has moved. So a refresh asks the tracker
whether it has before asking it anything else: one bd sql --json that
answers dolt_hashof_table for every base table in the Dolt working set
except leases, a row each, which bdi folds into one fingerprint. The
working set holds everything the database does, committed or not — including
the wisps, which live in dolt_ignored tables and never move the committed
head. A project whose fingerprint is where the last successful read left it
is done there, and its freshness is as good as if the cascade had run: a
skipped read is a successful read, and the project line says so. A project
whose fingerprint has moved is read in full.
A read made once asks for no fingerprint. --json and --beads read each
tracker a single time and exit, so nothing would ever be compared with the
probe's answer.
The cascade's calls are asked together. list, query, ready and
blocked need nothing from each other, so all four are in flight at once and
a read waits for the slowest of them rather than for the four in turn. bd
1.3.0 answered four --readonly reads at once on one embedded store eighty
times out of eighty, measured 2026-10-04 on a throwaway store.
Measured 2026-09-01 against this project's tracker with bd 1.2.2, when the
probe was the whole working root, dolt_hashof_db(): the probe 0.203–0.205 s
against 1.53 s for the cascade; a read does not move the root (three probes
with a full bd list --all and a bd query between them returned the same
hash); a wisp write moves the working root and leaves the head identical,
which is why the head was rejected as the probe; an ordinary bead write moves
both.
leases is left out because a heartbeat writes it and nothing else. From
beads 1.3.0 a claimed bead's lease lives in that table, which is
dolt_ignored, and a heartbeat is an UPDATE leases with no Dolt commit. It
moved the whole working root on every heartbeat, so a tracker with a live
claim was read in full on nearly every poll. Measured 2026-09-23 on throwaway
1.3.0 and 1.2.2 stores, reading the whole root and this probe after each
step:
| step | whole root | this probe |
|---|---|---|
| idle | still | still |
| claim | moves | moves |
| heartbeat, twice (1.3.0) | moves each time | still |
| wisp create | moves | moves |
| plain update | moves | moves |
| a full read | still | still |
On a 1.2.2 tracker there is no leases table, and the probe moves exactly
when the whole root does. The two cost the same to within noise. What it
gives up is the lease itself: a 1.3.0 bd list --json carries
lease_expires_at and heartbeat_at on every row, and a heartbeat changes
them without moving the probe. So a [[badges]] key naming either one is
redrawn only when something else in the tracker moves, and
configuration.md says so. Hashing leases for just the projects whose
badges name a lease field was weighed and turned down, because it is not
worth the plumbing for an edge case.
The hashes come back a row each rather than as one GROUP_CONCAT, because
Dolt cuts that at group_concat_max_len, 1024 bytes by default, and ignores
a SET_VAR hint raising it. A 1.3.0 tracker's hashes already come to 956
bytes, and a cut would leave every table past it unable to move the probe.
Three things it has to get right. The fingerprint is stored only after the
cascade that followed it succeeded, or a failed read would be sticky. A
tracker that cannot answer the probe — a SQLite-backed one has no
dolt_hashof_table — gets the cascade, never "nothing changed": degrade,
never disappear. Within that,
a tracker that refuses the probe is told from a server that did not answer
it, because the two want different next moves. bd's default store is its
embedded Dolt, and bd sql there is refused with 'bd sql' is not yet supported in embedded mode on every bd from 1.0.4 to 1.2.2 (measured
2026-09-02): the adapter remembers that refusal per project for the run, and
the tracker is read in full from then on with no probe process in front of
it. A server that did not answer is asked again next refresh, so an outage
never costs the fast path once the server is back. And where a
producer's message arrives for a project whose fingerprint has not moved, the
probe wins and the cascade is skipped: bd commits before it returns and a
wrapper pings after, so a real write has already moved the fingerprint by the
time the message lands, and an unmoved fingerprint means the write was a no-op
or the producer was wrong.
bd sql is the one subcommand --readonly does not veto, so the guard the
flag gives the rest does not reach it. What stands in its place is bdi's own
string literal — a constant nothing composes, reached from one function that
takes no argument.
bdi watches as well as polls. Anything that already knows a tracker changed
can say so, and the project it names is read then rather than at its next
interval. bdi ships the socket and the protocol; what produces for it is the
setup's business, and deliberately none of bdi's. A built-in watcher for bd,
Dolt or git is exactly the coupling to one setup's organisation this project
forbids — and the one real producer measured, a Dolt binlog consumer, needs a
replication user and a server-unique id that a DB-scoped tenant cannot have,
and receives every tenant's rows on one stream. An interface fits every setup:
a Dolt trigger, a git hook, a bd wrapper, a bd serve event stream, a systemd
path unit, a cron comparing a head hash, someone typing the line.
The socket. A stream socket created mode 0600, at
$XDG_RUNTIME_DIR/beady-eye/changes.sock unless the run is told a path — by
[changes] socket in the config, or by --socket for one run. The mode is
set on every run rather than left to where the socket sits: a derived path sat
under a directory the session owned, which needed no privilege to create in
and no other user could reach, but a told path may sit anywhere and /tmp is
world-traversable. Both platforms check that mode when something connects, so
it is what keeps other users off the channel rather than a hope about where the
socket sits. Measured 2026-09-07 on Linux 7.2.3 and on Darwin 25.6.0, with one
program run on each: a socket its own owner sets to 0400 refuses that owner
and one set to 0200 takes them, which is write permission being checked
rather than the bits being read and ignored, and a socket 0600 under another
user refuses this one. Darwin's unix(4) says the same in its own words —
Normal filesystem access-control mechanisms are also applied when referencing
pathnames; e.g., the destination of a connect(2) or sendto(2) must be
writable. It is
told rather than derived for two reasons. A path derived per session is one
path, so a second bdi beside a first is refused the channel and polls for the
rest of its life — the normal case wherever one person runs more than one. And
a machine that owns no runtime directory at all, which is macOS, has nothing to
derive and no channel until it is told one. --socket as well as the key
because two simultaneous runs of one binary read one config file, so a setting
they must differ by cannot live only there. bdi removes the socket when it
exits, and reclaims a stale one left by a run that crashed. A unix socket
rather than a signal because the message must carry which project changed —
bdi watches several and refreshing all of them throws away the saving — and
rather than a FIFO because a FIFO handles several writers badly.
Where the socket may sit. A mode says who may reach the socket and nothing
about who may replace it, which the directories above it say. bdi makes a
directory it creates 0700, takes one already there as it stands, and refuses
to bind under a directory somebody else may take a name in — one owned by
another user, or one a group or everybody may both write and search without
the sticky bit. Both bits, because making a name needs the directory searched
as well as written, so reading the write bit alone would refuse a directory
nobody but its owner can touch.
Every directory on the way down, rather than the socket's own. Renaming a directory aside and putting your own there gives you every name beneath it, so a private directory under a shared one is as open as the shared one.
The root is the one exception, and only above the socket's own directory. A
refusal is answered by naming another path and no path higher up leaves the
root out, so refusing there would say the machine cannot have a channel rather
than saying where to put one — and a root somebody else owns is a whole
filesystem somebody else owns rather than something a socket is the place to
find out: / inside a nix build sandbox belongs to 65534.
Where the socket's own name is in the root there is another path to name, one
directory deeper, so that one is judged like any other.
Both how the path is spelled and what it resolves to, because neither covers
the other. A link is followed somewhere else entirely, so what a name means
is what the links in it point at; and a link is reached through the directory
holding it while appearing nowhere beneath what it points at, so a resolved way
down alone would judge where a link goes and never the directory anybody may
repoint it from. Reading both is also what makes the answer keep until the
bind that follows it: every directory either way down is this user's or the
system's, so there is nobody left to move a link or a directory in the meantime.
A way down that cannot be read is refused rather than passed. Reading a
directory is how it gets cleared, so one that cannot be read is one nothing
has cleared. The owner of a directory above the socket can make the reading
fail whenever they like — a link pointed at itself for the moment the check
runs, and back before the bind that follows it — so letting an unreadable
way down through would hand them every check above at once.
The owner as well as the mode, because an owner may always take any name in
their own directory. A directory belonging to somebody else is one they may
take the socket's name in however narrowly it is set — theirs at 0755 is no
better than anybody's at 0777 — so the only owners a directory on the way may
have are this user and root. root is not a concession, since it can reach
anything on the machine whatever a directory says.
The user read is the effective one, since that is the user the kernel weighs a directory's owner and mode against, and so the user whose answer this walk is predicting. They are the same on an ordinary run and part company under a setuid wrapper.
Reading the owner costs geteuid, and that costs two things. libc becomes a
runtime dependency, where it was a dev-dependency for the pty harness — the
crate is compiled either way, as signal-hook's own, but what ships now names
it. And the call is the crate's first unsafe block: geteuid takes no
arguments, reads no memory and cannot fail, which is the mildest crossing
available, and nothing in std says which user a process is.
The alternative is a crate wrapping it safely, which trades three lines for a
dependency of substance.
No test drives the real runtime directory, on either platform. Every test
that opens a channel builds the directory it puts the socket in, so the suite
exercises this walk over its own scaffolding and never over /run/user or
/private/tmp — and a nix build sandbox has no /run/user to drive it with
even if one wanted to. It is checked by running bdi against the real path by
hand, which is what caught the sandbox root above.
The sticky bit is what keeps the obvious path usable. /tmp and /var/tmp are
1777 and root's on both platforms, so a socket bound there stays the socket
that was bound however many people may write beside it.
What the rule buys is the two unlinks — the one reclaiming a crashed run's socket at the start and the one clearing this run's away at the end. Each looks at what is at the name and then removes it, and no unlink takes a file to check against, so where the name can change hands they narrow the window and cannot close it. Where it cannot, there is no window: the file is this user's and removing it is this user's to do, or it is somebody else's and the remove is refused.
The refusal is said and polled like every other, and it names the directory at fault, which is not always the one the socket was to sit in — so the remedy it gives is a path with no such directory above it rather than a deeper name, which under a shared directory would be advice to walk further into it.
The protocol. Send the name of a project whose work has moved, as one UTF-8
line ending in \n. Or send covered <project> to say you are watching that
project and nothing in it has moved. bdi answers each line with one line of
its own:
| answer | meaning |
|---|---|
ok <project> |
a project bdi watches. A bare name is read again, and no other project is. A covered one is not read |
unknown <project> |
not a project this bdi is reading. Nothing happens |
malformed |
blank, or longer than 512 bytes. Nothing happens |
A covered line is how a producer says it is alive. A producer worth having
speaks only when something changes, so over a tracker nobody touches it has
nothing to say, and it reads exactly like a producer that has died. The
covered line lets it say otherwise without costing a read. The word goes before
the name so that a producer that only sends bare names keeps working
unchanged. A producer that sends covered to a bdi older than the word is
answered unknown covered <project>, which is how it can tell. The one cost is
that a project whose own name begins with covered cannot be reported bare.
The name must match a project's name in the config. A connection may carry as
many lines as you like and may stay open for the life of the writer, so a
long-running producer connects once and speaks whenever it has something to
say. bdi never initiates; it only answers. The answer goes back to the writer
rather than onto the screen because the writer is the only one who can fix a
wrong name — the person running bdi is watching a forest, not a log. A
malformed or unknown message is reported and dropped without disturbing the
loop or the other sources; it arrives from outside bdi and must not be able
to take the view down.
No config key says which projects have a producer, and that absence is the
design. bdi cannot know in advance which projects something reports for,
and a key saying so would be exactly the coupling to a setup's organisation
this bead exists to avoid. Instead every project starts polled; one something
reports for has its poll stood down while messages keep arriving inside the
refresh interval, because each message's read pushes the next poll out past
the interval before it arrives, and each covered line pushes it out with no
read at all; and a producer going quiet lets the next interval find the
project uncovered, so the poll resumes — the view degrades to slow, never to
stale. Selection and fallback are one mechanism with nothing to tune. A
project whose producer you trust can turn its poll off with poll = false,
which is a claim rather than a saving: nothing then covers for a producer that
dies, and an automatic fallback would hide the failure you need to see.
--poll and --no-poll override every project for one run.
What shows that failure is the lapse. A project that does not poll is
vouched for by each read of it that comes back and each covered line naming
it. Once [changes] covered_for_seconds passes with neither, its mark turns to
? in the colour that asks to be looked at, and the age beside it goes on
counting from its last read. A change report renews it through the read it
causes. The next word or read puts ✓ back. A read on its way, one that has
stopped answering and one that came back short each keep their own mark,
since each says something the reader needs sooner. The run's first read
starts the clock, so a producer that never starts is caught one term in. The
term is a key of its own rather than refresh_seconds, which keeps meaning
the gap between a polled project's reads. Its default of 60 seconds is three
of the 20-second heartbeats a producer reading a Dolt event stream renews
from, so a late heartbeat or two does not read as a producer that has gone.
A polled project never lapses: its poll keeps it current, and that is the
reader's choice rather than a producer failing where nobody can see.
A socket that cannot be opened is said twice, deliberately, and the two are
not copies. No path to put it at, or another bdi already watching the
one it has, and this one polls everything exactly as it did before. The notice
at the foot says what it costs the reader — bdi cannot hear about changes ·
every project is polled instead, or another bdi is already watching for
changes where that is the cause, since that one names a process the reader can
close. The stderr line names the path and the io::Error under it, and the
remedy: a process to close where there is one, and where there is not, the flag
and the key that name a path — a reader with no runtime directory has none to
make appear, and without the remedy the line reads as a verdict on their
machine rather than as something to set. That is the actionable half and
precisely the half no phrase may carry on screen; it is written before the
alternate screen opens, so it survives the teardown and is still on the primary
screen when the view tears down, and it can be redirected to a file where a
status bar never can. A reader seeing an eprintln! beside
a status-bar notice should not delete either. The socket is asked for once and
never again, so a run that started without it goes on polling even after the
path comes free — closing the other bdi frees the channel for the next run,
not for this one, and the notice is worded in the tense of the refusal for
that reason.
The notice retires after a minute; the stderr line does not. Nothing
rechecks the refusal, so nothing but a clock would ever take the notice off —
and on a machine that sets no $XDG_RUNTIME_DIR the refusal is true of every
session there will ever be, so a foot that said it for the life of the run
would spend one of its few rows describing the ordinary way bdi runs there.
A minute is long enough for a reader who started bdi and turned to another
window, and short enough that a session left up all day is not paying for it.
What goes at the end of it is the sentence and not the fact: the stderr line
is still on the primary screen where it was written, carrying the path and the
remedy no phrase could.
README.md carries the worked example: a bd wrapper that writes the project
name to the socket after any command that wrote something.
A view is not the only thing that wants to know what a tracker holds. A script mirrors beads into another system, a bar widget counts the beads carrying a label, an agent waits for an answer to a bead. Each could poll its tracker, and each would pay a read's cost to learn, nearly every time, that nothing moved. So one process per machine reads every configured tracker, keeps what it read, and sends it to whoever asks: the beads as they stand when a consumer connects, and each change after that.
| consumer | watches | acts on | wants it within |
|---|---|---|---|
| a view | each project it draws, closed beads included | every bead with its parent and dependencies, readiness, and each project's freshness | seconds, and one poll is tolerable |
bdi --json and --beads |
the same, read once | the same | no bound, since the answer says how old it is |
| a script mirroring beads into another system | its own project, and single beads in other projects | status and ready, and only while the tracker is answering |
minutes |
| a bar widget counting beads | one project, or all of them | the beads carrying a label or a status, counted | one poll |
| a list of beads waiting on a person | every project | the beads carrying a label, and their text | one poll |
| an agent waiting for an answer to a bead | one bead | a comment arriving, with its text, or the status changing | seconds |
| a process waiting on several beads | those beads, and a project for the beads created under one of them | status, metadata, and a new bead's parent |
one poll |
| an inbox of ready work | one project | ready turning true, and who made the change, so it can pass over its own writes |
minutes |
| a process woken by some changes and not others | one project | what kind of change it was, and to which bead | seconds |
| a relay to a chat or a pane | one bead | the same as the agent waiting | seconds |
Three things follow.
- A consumer learns of beads it has never heard of. A view draws a bead created after it connected, and a process waiting on an epic wants each child as it is filed. So a consumer watches a project as readily as a bead.
- Edges are state. A view redraws when a parent or a dependency changes, so what is sent about a bead is everything its tracker's row says.
- Some consumers want the change and not only the state after it: who made it, what kind it was, the text of a comment. bd's events journal records that, and bd's events below says how it is used.
Two wants are left out. A pane changing state is the agent provider's to
report, and the watcher never asks it. A due date arriving changes nothing a
tracker holds, so there is nothing to send. A defer_until arriving does
change ready, and that is sent.
The watcher is bdi watch, a run with no view. It holds every
configured project's beads as last read, and how current that read is, and
sends each consumer the beads it watches. It does not ask the agent provider
anything, because nothing it reports comes from a pane. Starting it and
keeping it running is the setup's business, under whatever supervises a user's
processes there: a systemd user unit, a launchd agent. bdi ships no unit, for
the same reason it ships no producer.
Where its answers come from is a seam. A change source hands the watcher three things for each project: every bead the tracker holds, wisps among them, with whether each is ready and what blocks it; how current that is, as of when or why the project could not be reached; and bd's event records since it last answered, where the project has them. What a consumer is sent is decided from that alone, so a source can be replaced without anything that watches or delivers changing.
The first source reads trackers as a view does. It polls, probes and takes
producers' lines as Refresh describes, and reads in full a project whose
probe has moved. So a change reaches a consumer within one poll of the project,
refresh_seconds, or as soon as a producer reports it. Two changes to a bead
between one read and the next arrive as one bead line: a bead closed and
reopened inside one poll is never sent closed. The source keeps each row as bd
printed it and not as bdi parsed it, so a field bdi never reads still
reaches a consumer, and a field a later bd adds needs no change here.
One per machine, found by its path. The watcher's socket is its own, at
$XDG_RUNTIME_DIR/beady-eye/watcher.sock unless it is told a path, and it
makes every check Where the socket may sit makes. It is a separate path
from a view's inbound channel because that channel goes to whichever run asks
first, and a view started while the watcher was restarting would hold it for
the rest of its life. The socket takes a producer's lines too, a bare project
name and covered <project>, and answers them as the inbound channel does. A
second bdi watch finds the first by connecting, as a view finds another
view, and exits saying which socket is taken.
A run of bdi believes only a socket that is the user's own. Before it
connects, it makes the same checks on the way down that the watcher makes,
and it requires the socket itself to belong to the user. A sticky directory
such as /tmp passes the way down, yet lets another user bind a name there
first. Once the name is the user's own, nobody else can replace it. A run that
finds any other socket at the path takes it as no watcher at all.
A consumer connects and sends one line for each thing it watches:
watch every project the watcher reads
watch summit-works one project
watch summit-works smt-4kd3p.20 one bead
watch-all summit-works one project, closed beads included
The key is (project, id) as everywhere else, so a blocker in another project
is watched the same way as a bead in the consumer's own. A connection may
carry as many lines as the consumer likes, and a bead two of them name is sent
once.
The watcher answers with the beads as they stand, then with each change.
watch on a project starts from its beads that are not closed. watch-all
starts from every bead, which is what a view sends, because it draws closed
beads too. The two differ only in where they start. A tracker's closed beads
come to outnumber the rest many times over, and a consumer acting on live work
should not be sent them each time it connects. From there both send every
change, so a watch is told of a bead closing and of a closed bead reopening.
A bead named on a line of its own is sent whatever its status.
Every line the watcher sends about a watch is one JSON object, and its line
says which kind it is.
A bead line is one bead as its tracker now has it:
{ "line": "bead", "project": "summit-works", "ready": false, "blocked_by": ["smt-4kd3p.13"], "bd": { "ready": false, "blocked_by": ["smt-4kd3p.13"] }, "row": { "id": "smt-4kd3p.20", "title": "the daily wallpaper timer calls dms", "status": "blocked", "parent": "smt-4kd3p", "dependencies": [ { "depends_on_id": "smt-4kd3p", "type": "parent-child" }, { "depends_on_id": "smt-4kd3p.13", "type": "blocks" } ], "labels": [], "metadata": { "blocked_on": "human" }, "comment_count": 3, "updated_at": "2026-08-30T10:21:02Z" } }row is bd's row whole, cut here to the fields this section names. ready
and blocked_by are the values --beads gives, so a blocker in another
project counts. bd holds the same two as bd itself gives them, from this one
tracker. They sit beside the row and not in it, so nothing bdi adds can be
taken for bd's or collide with a field a later bd adds.
A bead line is sent the first time a watch reaches a bead, which is how a
consumer learns of one created after it connected, and again whenever the row,
ready, blocked_by or bd differs from what that connection was last sent. A row
differing only in lease_expires_at or heartbeat_at is not sent: a claim's
heartbeat writes those and nothing a consumer acts on.
A gone line names a bead its tracker no longer holds, and answers a watch for a bead it never held:
{ "line": "gone", "project": "summit-works", "id": "smt-4kd3p.21" }A freshness line says how current a project's beads are:
{ "line": "freshness", "project": "summit-works", "as_of": "2026-08-30T10:22:14Z", "tracker": "ok", "events": "ok", "protocol": 1, "reach": { "path": "/home/mira/summit-works", "environment_command": ["direnv", "exec", "."] } }It closes every answer the source gives for the project. It follows the beads
a watch starts from, it follows each batch of changes, and it is sent alone
when the source vouched for the project and nothing had moved. So it also
tells the consumer it is current: everything sent before it is the project as
of as_of. as_of is the instant the source last vouched for the project,
which for the reading source is its last read that came back, a skipped one
included, or the last covered line naming the project. tracker is ok, or
{ "unreachable": <reason> } with the reason --json gives, where the last
attempt to reach the project failed. The beads already sent then stand as the
last known, and as_of says how old that is. A project that has never been
read is sent no beads, and its as_of is null. events is explained under
bd's events. reach is the path and environment_command the watcher's
config gives the project, which together decide the tracker bd reaches, with
the command as a list of words. A consumer watching one bead is sent its
project's freshness line.
protocol is the version of every line the watcher sends about a watch. A
watcher is long-lived, so after an upgrade it can be an older bdi than the
consumers reading from it, and a line whose meaning changed while its shape
did not would be believed. A consumer that finds protocol missing, or a
version it does not know, treats the watcher as down. The version moves only
for a change a consumer cannot read as it read the version before. It rides
on the freshness line rather than on a greeting, because producers share the
socket and read their first line as their answer, and because a consumer
takes nothing from an answer before its freshness line arrives.
An alive line goes out on every connection every 20 seconds, whatever else has been sent:
{ "line": "alive" }An event line is one of bd's event records, and bd's events has it.
A line the watcher cannot serve is answered with why, and changes nothing else on the connection:
{ "line": "refused", "asked": "watch summit-work", "reason": "unknown-project" }unknown-project is a project the watcher does not read, and malformed is
a watch or watch-all in none of the four forms above. A bdi older than
watch answers unknown watch <project>, the inbound channel's answer to a
name it does not know, which is how a consumer can tell it has reached one.
The words cost what covered costs: a project named watch or watch-all,
or whose name begins with either and a space, cannot be reported bare on this
socket.
- A refused connection, or one the watcher closes, is a watcher that is not running. Nothing has been said about any bead, and a consumer that acts only on what it is told should not act.
- An open connection that has carried no line for a minute, when an alive line is due every 20 seconds, belongs to a watcher that has wedged, and is treated as closed.
- A watcher that is alive and cannot reach a tracker says so in the project's
freshness line, and does not report its beads unchanged. How old
as_ofmay be before a consumer stops trusting it is the consumer's call, since only the consumer knows how long its question can wait.
Nothing is lost across a reconnect. A consumer that reconnects sends its lines again and is sent the beads as they now stand, so a close that happened while it was away arrives as the bead's status. No consumer keeps a checkpoint.
So the watcher closes a connection whose consumer has stopped reading, once answers have piled up waiting to be written to it. Holding them without end would cost the watcher memory for as long as the consumer does not read, and the consumer loses nothing by reconnecting.
A consumer that wants one answer connects, sends its lines, reads as far as each project's freshness line and hangs up.
Replacing the watcher is stopping it and starting another. Its consumers see their connections close, reconnect and send their lines again. Until the new watcher's source has answered for a project, a watch on that project waits for the answer and is not told the project is empty.
bd 1.3.0 can keep an events journal: one record for each mutation, holding a
seq that rises without gaps, the op, the bead's id, the actor, the bead
as the mutation left it, and the dependency or the comment where the op has
one. bd events tail prints them, and its help calls the record a stable
contract for outside consumers. What follows was read from that bd's source
and checked against a journal it had written, 2026-10-04.
The records are what a consumer is sent, unchanged. Three wants in the
table are in a record and in no row: who made a change, each change in order
where several land between two reads, and the text of a comment. A bead whose
blocker closes gets a record of its own, with no actor. So where a project
keeps a journal, the watcher sends each record as an event line, to every
consumer watching the bead its issue_id names:
{ "line": "event", "project": "summit-works", "event": { "seq": 412, "ts": "2026-08-30T10:21:02Z", "op": "comment", "issue_id": "smt-4kd3p.20", "actor": "Mira Vance", "issue": { "id": "smt-4kd3p.20", "status": "blocked" }, "comment": { "author": "Mira Vance", "text": "Guard it in the parser.", "created_at": "2026-08-30T10:21:02Z" } } }event is bd's record whole, with issue and comment cut here for length.
The records are not what the watcher holds. Its beads come from reads, for five reasons:
- A record's
issuecarries no parent, no dependencies and no comment count. An edge arrives only as adep_addordep_removerecord, so the graph could only be had by replaying every record since the tracker began. - The journal is off until a writer's own clone turns it on, and bd tells a reader nothing about the other writers. One writer without it leaves a gap nothing reports.
- Nothing is recorded when time alone makes a bead ready, or when beads arrive
by
bd dolt pull. - The journal starts when it is turned on and is pruned after that, so it cannot say what a tracker held before either.
- Whether a bead is ready across projects is in no one tracker's journal.
A project says in config that it keeps a journal. events_journal = true
on its [[projects]] entry is the setup's word that every writer to that
tracker journals. It is a claim, as poll = false is, because bd cannot
answer the question. A project without the key is sent no event lines, and its
freshness line carries "events": "off". One with it carries "ok", or
{ "unreadable": <reason> } where the journal would not answer.
Nothing stands in for a journal a project does not keep. The watcher
could compare two reads and call each difference an event. Such a record would
have no actor and no seq, would miss every change between the two reads, and
would look like bd's. So a consumer of a project with no journal is sent bead
lines, which every consumer is sent, and the freshness line tells it not to
wait for events.
The journal is read when the project has moved, and not otherwise. When a
project's probe moves, the source runs bd events tail --since with the last
seq it read, and then reads the rows. A quiet tracker costs nothing more
than its probe, and a record reaches a consumer as fast as a bead line does:
within one poll, or as soon as a producer reports the project. The watcher
keeps each project's last seq in memory and nowhere else, and starts from
the journal's end as it finds it. It finds the end by reading the whole
journal once, at its first read of the project, and sends none of it,
because bd answers no cheaper question about where a journal ends. bd
refuses a read from below what it has kept, and its refusal names the oldest
and newest seq it holds. At the first read the newest is the end. Later,
the refusal means records were pruned before the watcher read them, so the
freshness line says the journal is unreadable for that answer, and the next
read starts at the oldest record bd kept. Any other failed read leaves the
seq where it was, so the records are read once the journal answers again.
bd can also follow a journal, with --follow, and a record would then arrive
about a second after it was written with no producer involved. It was turned
down for what it costs: a follow asks its tracker a query or two every second
for as long as it runs, one follow for each project on each machine, and bd
ends it on any read error.
Because the journal is read before the rows, a batch is sent in that order: its event lines, its bead and gone lines, then the freshness line. A consumer that acts at the freshness line therefore has, for every event in the batch, a bead line at least as new.
Events are not replayed. A consumer is sent the events from the moment it
watches, and one that was away is not sent what it missed, because the bead
lines it gets on reconnecting already hold the outcome. A consumer that must
see every record, whoever was connected, reads bd events tail itself and
keeps its own seq.
Where a watcher is running, a view watches it and reads no tracker
itself. It sends watch-all for each project it draws, builds its trees
from the bead lines as it builds them from a read of its own, and draws each
project's freshness from the freshness lines. Each freshness line has the
view collect that project again, as a producer's line does, so a change
reaches the screen as soon as it reaches the watcher. ^R sends each
project's name to the watcher on a connection of its own, which the watcher
takes as it takes a producer's. The run starting sends none, because the
watcher has already read every project. The view still asks the agent
provider and still makes the join, because the watcher does neither. It
keeps its own inbound channel, and passes on to the watcher each line a
producer says there, because the view draws what the watcher holds. bdi --json and --beads watch the same way, read as far as each
project's freshness line and hang up.
Where none is running, a view reads its trackers itself, as Refresh describes. A setup that starts no watcher loses nothing by it. One that starts one has each tracker read once on a machine, however many views, one-shots and other consumers are looking at it.
A view whose watcher goes away reads every project itself at once,
rather than waiting for each project's next poll, because the answers it
holds would go stale with nothing to say so. It looks for a watcher again
every refresh_seconds, which is as long as a project read by the view
itself waits for its next poll. A view that started with no watcher looks
for one the same way.
A one-shot reads for itself every project the watcher does not answer
for. The watcher answers for a project when its freshness line arrives
with the reach the run's own config gives the project. A config that names
no socket finds the one watcher its session runs, whatever config that
watcher read, so a run under another config, or under one changed since the
watcher started, can find the name answered from another tracker. A
different reach, a refused or closed connection, a refused watch line, a line
the run cannot read, a protocol it does not know, or a minute with no answer
for a project leaves that project to bd, and a connection that has gone
leaves every project still unanswered to bd as well. The projects already
answered stand, so the run gives a whole answer either way, each project read
once by one of the two.
What the run takes from a bead line is what bd would have told it: the row
and bd. The run's own trees work out bdi's readiness again, so the screen
and --json say what a read of their own would say, whichever projects it
read itself.
A one-shot is dated to the oldest read it was drawn from. For a project
the watcher answered, that read is the freshness line's as_of. So
generated_at says how old the answer is, which is the only bound a one-shot
consumer has.
An agent that has asked a question on a bead, or is waiting on someone else's bead, has no way to learn that the bead changed short of asking again. The watcher already knows, and an agent session cannot hold a connection to it open between turns. So the beady-eye plugin holds the connection for it: a Claude Code plugin, shipped from this repository, that watches the beads a session names and puts each change into the session as a message, which wakes the session if it is idle.
The plugin is a consumer like any other. It connects to the watcher on its
own machine and sends the lines Watching describes. bdi knows nothing of
it, and everything Claude Code needs lives in the plugin. It relays nothing
through chat, which would need a topic per bead.
The plugin's server is TypeScript speaking MCP, written in Effect. It makes
the same technology choices as the server of commy, the Claude Code chat
plugin, wherever Rust is not an option. It is built with Bun into
one file, published to npm, and started by the plugin's .mcp.json with npx at the plugin's exact version, so a user needs
Node and nothing else. The repository's root carries the
.claude-plugin/marketplace.json that makes it a marketplace, so the plugin
installs as beady-eye@beady-eye.
A machine chooses how long npm holds the server's releases. npm's
min-release-age refuses a release younger than it, so a session started
where it is set cannot start the server of a plugin released since. The plugin's
optional NPM_MIN_RELEASE_AGE setting reaches npm as
npm_config_min_release_age for the server alone, as commy's does. Unset, it
reaches npm empty, which npm ignores, so the age an .npmrc sets stands. An age
set in the environment Claude Code started in does not: Claude Code writes the
empty value over it, since .mcp.json cannot leave a key out.
A change arrives as a channel message. The server declares Claude Code's
claude/channel capability and sends notifications/claude/channel, which the
session sees as a <channel source="beady-eye" …> block. Claude Code delivers
a plugin's channel messages only to a session started with that channel
allowed. Without it the plugin's tools still answer and no change arrives, so
starting sessions that way is the setup's business, as starting the watcher is.
The server learns its session as commy's does. Claude Code starts it with
the session's id in CLAUDE_CODE_SESSION_ID, and a PreToolUse hook adds the
id and working directory to each call to the plugin's tools, because MCP tells
a server neither. The server takes the first id it is given, from either, and
keeps it.
A session is told how to use the plugin. The server sends MCP
instructions that every session loading the plugin receives: what the three
tools do, how a change reads, and that a watch lasts until unwatch. The
plugin also ships a using-beady-eye skill, which a session loads when it
waits on a bead or a change arrives, on what to watch, how to act on a change,
and when to unwatch. When a particular setup's agents should watch a bead is
that setup's to say.
The plugin reads [watcher]'s socket from ~/.config/beady-eye/config.toml,
and where the config names none, takes $XDG_RUNTIME_DIR/beady-eye/watcher.sock
as bdi does. Where neither gives a path, as on a Mac whose config names
none, there is no watcher to reach, and the plugin says so as it says a
watcher is down. It believes only a socket that is the user's own, by the same
checks A run of bdi believes only a socket that is the user's own makes.
The plugin has three tools:
watchtakes a bead's id and, optionally, its project. It answers with the bead as it stands: its title, status and whether it is ready. Changes after that arrive as messages.unwatchtakes the same, and stops them.watchinglists what the session watches, each bead's last known status, and whether the watcher is answering.
A bead named without its project is found by asking the watcher. The
plugin opens one short connection, sends watch <project> <id> for each
project the config names, and reads as far as each freshness line. The project
that answers with a bead line holds it. Where none does, or more than one
does, watch refuses and asks for the project. It refuses too where a
project the watcher has not read, or one that has not answered within 15
seconds, leaves the answer open. The key is still
(project, id), and the plugin never guesses a project from an id's prefix.
watch refuses a project the watcher does not read, and a bead its project
does not hold. Waiting cannot mend either, so each is an error at once
rather than a watch that never answers. The refusal names the project that
answered, for a session that named the wrong one.
Each watched bead has a connection of its own. The protocol has no line to
stop a watch, so unwatch closes that bead's connection, and a connection
lost is that bead's alone to restore.
A session's watches outlive its server. Claude Code restarts an MCP server
under a live session, and a session can be resumed later under the same id. So
the plugin keeps each session's watches in a file of its own under
$XDG_STATE_HOME/beady-eye/watches/, named for the session's id, and
watches them again as soon as it learns that id. A server started with the id
in its environment learns it before any tool is called, which is what brings
back the watches of a session asleep on a bead. On each watch and unwatch
a new file replaces the old one whole, so a server stopped while writing leaves
the last list standing. The file is never removed, as commy keeps its
subscriptions.
A watch ends when the session unwatches the bead, and not when the bead
closes, because a closed bead can reopen. The server exits when the session
closes its input, and every watch's connection closes with it.
A session wakes for what a waiting agent acts on, and for nothing else.
That is a bead's status changing, with its close_reason when it closes;
ready turning true or false; a comment arriving; and the bead going from its
tracker. Every other change to the row sends nothing. An agent writes to the
bead it waits on, to keep the question on it current or to say where it can be
found, and no line tells the plugin who wrote: a bead line carries no actor,
and an event line carries one only where the project keeps a journal, and two
writers to one tracker can sign as the same actor. So the kind of change is
the only thing that can keep a session from waking on its own writes.
One message for each batch. The watcher closes each batch of changes to a
project with its freshness line. The plugin compares each watched bead with
what it last told the session, and once the freshness line arrives sends one
message for each bead with something to say. bd human respond adds a comment
and closes the bead in one write, and the session wakes once for both.
The message names what changed. It gives the bead's id and title, and the
old and new value of each thing that woke it. Where the project keeps bd's
events journal, a comment comes with its author and text. Where it does not,
the message says how many comments arrived, and the session reads them with
bd. The channel block's attributes carry project, id, status and
ready, so a closed bead shows as one without reading further.
What a session is told is kept, so a reconnect is not news. The beads the watcher sends when the plugin connects again are compared with what the session was last told, as any batch is. A restart of the watcher with nothing changed sends nothing, and a bead that closed while the plugin was away arrives once, as a close.
A session waiting on a bead is told when it can no longer be told, because it has no other way to find out and would otherwise wait for ever. A watcher refused, closed or wedged is down, as A quiet watcher, and one that has gone says. The plugin connects again with a growing pause and says nothing for the first minute, because the watcher restarts on each upgrade and each edit to its config. Past that minute it sends one message naming the beads it cannot watch, and saying that the watches are kept and the session will be told when the watcher is back. When it is back, one message says so, with any watched bead that changed meanwhile. bd's event records from the gap are not replayed, so a comment made while the watcher was down arrives as a count and not as text, and the message says so.
A protocol the plugin does not know is said at once. Connecting again cannot mend it. The message says the plugin and the watcher need releases that speak the same protocol.
A tracker the watcher cannot reach is told the same way. A watched bead's
project whose freshness line has said unreachable for a minute sends one
message, and its first ok after that sends another.
Watching while the watcher is down is accepted. watch records the bead
and answers that the watcher is down, and the bead is watched from when it
comes back.
The plugin has a version and releases of its own. Its version is written
in its plugin.json, its npm package and the pin in its .mcp.json, and
nix flake check holds those together as it holds bdi's four. Its notes go
under RELEASE-NOTES/plugin/, and the Release workflow cuts a
plugin-v<version> tag, publishes to npm and announces the release, marked so
it does not displace bdi's as the latest. A user pins the marketplace to a
plugin tag. A plugin and a watcher from different releases meet anyway, since
the watcher is long-lived and installed apart from the plugin, and the
freshness line's protocol is what keeps them honest.
bdi --json emits the model, one whole collection.
{
"generated_at": "2026-08-30T10:22:14Z",
"agents": {
"provider": "herdr",
"state": "answering",
"sessions": [
{ "name": "default", "state": "answering" },
{ "name": "kadath", "state": "not-answering" }
]
},
"filter": "live-agents",
"trees": [
{
"project": "summit-works",
"root": "smt-4kd3p",
"title": "Switch larkspur's session shell from DMS to noctalia v5",
"counts": { "total": 21, "finished": 8, "live_agents": 3, "anomalies": 3 },
"tracker": "ok",
"nodes": [
{
"project": "summit-works",
"id": "smt-4kd3p.20",
"title": "the daily wallpaper timer calls dms",
"status": "blocked",
"issue_type": "task",
"priority": 2,
"depth": 1,
"edge": "parent-child",
"ready": false,
"blocked_by": ["smt-4kd3p.13"],
"started_at": "2026-08-29T10:00:00Z",
"closed_at": null,
"badges": [
{ "key": "blocked_on", "text": "⏸ waiting", "short": null, "link": null, "colour": null, "drawn_on": "own" }
],
"agent": {
"pane": { "session": "default", "id": "wCM:p9" },
"pane_status": "working",
"title": "shell selector + stable path",
"source": "agent_pane"
},
"anomalies": [],
"orphaned_dependencies": []
},
{
"project": "summit-works",
"id": "smt-4kd3p.16",
"title": "guard a key in both layers",
"status": "in_progress",
"issue_type": "task",
"priority": 2,
"depth": 1,
"edge": "parent-child",
"ready": false,
"blocked_by": [],
"started_at": "2026-08-29T10:00:00Z",
"closed_at": null,
"badges": [],
"agent": null,
"anomalies": [{ "rule": "orphan-claim" }],
"orphaned_dependencies": [{ "id": "mdw-9", "reason": "not-read", "projects": ["meadow"] }]
}
],
"beads_with_orphaned_dependencies": ["smt-4kd3p.16"],
"cycles": []
}
],
"hidden_trees": [ { "project": "summit-works", "root": "smt-3pd9k", "title": "…", "reason": "no-live-agent" } ],
"failed_projects": [ { "project": "meadow", "tracker": { "reason": "auth" } }, { "project": "dunwich", "tracker": { "reason": "parse", "read": "list", "cause": "invalid type: null, expected a string at line 1 column 25" } } ],
"unattributed": [ { "pane": { "session": "default", "id": "wCM:pD" }, "project": "summit-works", "cwd": "/tmp/bdi-ground/summit-works", "pane_status": "blocked", "display_agent": "smt-4kd3p.5", "title": "asleep: waiting on switch + reboot verification", "claim_refused": false } ],
"unconfigured": [ { "pane": { "session": "default", "id": "wCM:pF" }, "cwd": "/srv/spike", "pane_status": "idle" } ],
"conflicts": [],
"projects_named_without_git": []
}generated_at is the instant the oldest read behind the document was made:
when the run asked, or, for a project read through the watcher, when the
watcher last vouched for it.
nodes is pre-flattened in render order with an explicit depth, so a consumer
draws it without reconstructing the tree; edge says which kind of edge put
the node where it is (parent-child or blocks), and a bead reachable more
than once is in nodes once per way down to it. A node's project is the one
whose tracker holds the bead: the tree's own, or another project's where the
tree reached the bead through a bead waiting on it, so a node is keyed
{ "project", "id" } like every other bead here. That is the tree unrolled,
written at emission from a model that holds each bead once (see Tree
construction), so what --json says does not follow what the model stores. agent.source records which
direction of the join resolved it, so a consumer can tell a confirmed agent
from an inferred one. anomalies is every rule that fired, [] where none
did — never absent, never null; an orphan-claim the join refused carries the
refusing conflict as refused, and one it did not omits the field.
orphaned_dependencies is every blocker the bead waits on that no tracker
holds, [] where there is none, each an id and a reason: not-held with
the projects whose beads carry its prefix, held-by-several with the
projects that each hold a bead by it, held-by-unread with the one
configured project that gave no answer and whose config states its prefix,
not-read with the configured projects that gave no answer and may hold it,
or unconfigured with none. agents
says which agent provider was asked and how that went, so a consumer knows
which tier it is reading and which program answered for it: state is
answering, not-answering where the provider is there and did not — which
covers one that would not start at all — or absent where nothing was
installed to. sessions is every session the provider said it was running,
each answering or not-answering for its panes, and [] where the
provider never got as far as saying. A pane, wherever the contract names
one — agent.pane, unattributed, unconfigured, and every pane a conflict
names — is { "session", "id" }, the same shape as a bead's
{ "project", "id" } and for the same reason: an id is minted per session
and names nothing on its own. A tree's
tracker is ok, { "unreachable": <reason> } where its tracker could not
be read, or root-not-found where the tracker answered and holds no bead of
that id — which only a root named in config or on the command line can be,
since every other root came out of the tracker's own answers.
beads_with_orphaned_dependencies and cycles name ids that are still in
nodes. hidden_trees is never
empty-by-omission — a filtered tree is reported, not dropped. A reason is
{ "reason": <kind> }, tagged inside its own object the way an anomaly's
rule and a conflict's conflict are, so every reason reads the same way and
the ones that know more are found by their extra keys. parse is the only one
that knows more, and it carries read and cause beside its kind: read is
the bd subcommand whose answer would not parse, and cause is the shape that
did not match and where in the answer it was. failed_projects
names each project whose tracker could not be read at all, with the reason.
Two of those reasons are not about bd, and they are the two ways opening a
tracker fails. no-environment is a project that asked to be read in a captured
environment — by the command its config names, or by the .envrc in its own
directory — and did not get one. no-credential is a project whose
credential_command would not run. No bd was run for either. Every other
reason is a program that ran and would not answer, and a consumer that treats
them alike will report a bd fault on a machine whose bd is fine.
projects_named_without_git names each project here whose name git did not
give, because git could not be run — the directory its tracker sits at the top
of was used instead. It is [] on every machine that has git, and [] where a
config file or BDI_PROJECT named the project outright, because neither of
those is a guess. A name is half of every key in this document, so a consumer
holding one tests it against this list rather than being told once about the
run.
unattributed and unconfigured are the two ways a live pane resolves to no
bead, and a consumer tells them apart by the project key: an unattributed
entry always carries it, an unconfigured entry never does. The absence is the
contract, so test for the key rather than reading a null. An unattributed
entry also carries what the pane reported about itself, under the names a
node's agent gives the same things — display_agent, and its caption as
title — each null where the pane reported nothing.
claim_refused tells the two kinds of unattributed pane apart. A pane nothing
claims and a pane whose claim bdi read and would not honour are opposites
that arrive through the same array: the first is a seat that has not registered
or has finished and cleared, the second is a registration bdi understood and
refused. Which disagreement refused it is in conflicts, in full; this says
only that one did. It is published as the fact rather than left to be
re-derived, because a consumer working it out of conflicts for itself could
come to disagree with the screen about a pane the screen has already spoken
for.
bdi --beads writes the beads with the forest taken out, for a reader
choosing what to start rather than drawing a tree:
{
"generated_at": "2026-08-30T10:22:14Z",
"agents": { "provider": "herdr", "state": "answering", "sessions": [ { "name": "default", "state": "answering" } ] },
"beads": [
{
"project": "summit-works",
"id": "smt-4kd3p.16",
"title": "guard a key in both layers",
"status": "open",
"issue_type": "task",
"priority": 2,
"ready": false,
"blocked_by": ["smt-4kd3p.13", "mdw-9"],
"agent": null,
"badges": [],
"labels": ["human"],
"description": "Guard the key in the parser, the store, or both?"
}
],
"failed_projects": [ { "project": "meadow", "tracker": { "reason": "auth" } } ],
"unread_trees": [ { "project": "summit-works", "root": "smt-9zz", "tracker": "root-not-found" } ]
}beads is every unfinished bead in every tree the run read, keyed
{ "project", "id" } and written once however many ways down or trees reach
it, sorted by project and then by id. The live-agent filter does not apply,
so it cannot be combined with --all, --json or a bead named on the command
line, while --project and --all-projects choose the projects as they do
for any run. Its size follows the beads listed rather than the unrolled
forest's.
Each field is the one its tree's node carries, so --beads and the screen
cannot disagree. ready and blocked_by are bd's answer with the blockers
in other projects added, as Across projects says. So for a project with no
dependency on another project's bead, the beads with ready true are what
bd ready names. A blocked bead is listed too, because what blocks it is
what says which work is next. labels and description are as bd reported
them: a bead with no labels has an empty array, and one with no description
has null. bdi gives no label a meaning, so a reader wanting the beads
labelled human filters for them. created_at is the bead's created
date, null where the row has none. metadata is the bead's whole metadata
object, empty where it has none, each value as the text it prints as. bdi reads
no key's meaning, so a reader wanting one key picks it out of the object.
A list that is short says so. failed_projects is the forest's, and
unread_trees names each root whose tracker gave no rows, with the tracker
state its tree would carry, since the beads beneath it are missing.
There is no notices array here. A notice is the sentence the status bar makes
of a fact, and every fact it makes one of is already a field: the provider that
would not answer is agents.state, the session that would not is
agents.sessions, and a project name git did not give is
projects_named_without_git. Both mouths read those fields — the foot draws its
notices from the same snapshot --json prints — so a screen and a document of
the same moment cannot qualify a run differently. Publishing the sentences
beside the fields would put a derived value next to its input and give a
consumer two answers that can drift apart. So the document carries the facts
rather than the words, and an array would have to beat that rather than fill a
gap.
What that leaves out is what a one-shot cannot have. A bdi that prints one
collection and exits never opens the inbound channel and never re-reads its
config, so the view is only as fresh as the refresh interval and the config
would not reload are facts about a session that is still running, not about
this document. A field for them would be a field that is always absent. Where
a fact belongs to the run rather than to the process, it is published, and
degrade, never disappear is what says so.
orphan-claim fires on a claimed bead with no pane behind it, so it is only as
sound as the pane listing it was evaluated against — and agents is where a
consumer reads how complete that listing was. Every session answering means
the panes are every pane there is and each orphan claim stands on all of them.
A session not-answering means its panes are missing from this run, so a
claim naming a seat in it reads as unstaffed here whether or not the seat is
alive, and a consumer discounts the orphan claims accordingly.
A running screen can do better than that and --json cannot, and the
difference is memory rather than effort. Collection keeps which pane ids
each session last answered with, so a screen on its second collection knows
which ids the silent session was holding and suppresses exactly those claims.
--json builds one collection and exits, so it has nothing to remember by and
suppresses none of them: on the same tracker at the same moment, a one-shot
reports orphan claims the screen does not. That is not repaired by buying a
second collection — it would pay a round trip on every run for an exactness
agents.sessions already lets the consumer approximate — so the divergence is
published rather than hidden, and this paragraph is where a consumer meets it.
One scrollable forest. Each project owns a line, and the roots drawn for it hang under it as ordinary bead rows. The selected bead's pane tails below, and the foot of the screen carries the keys and every notice.
A project's line says what only a project can answer — which project, how fresh its rows are and how much work it holds. Everything else is a bead's, and a root is a bead: its status, its agent, its anomalies and its pane are drawn and reached exactly as any other row's are, on a row at depth one under the project. A count on a project line is over every tree the project holds, shown or held back by the filter, with each bead counted once, because a bead standing in several of them is still one bead. A project rests open: the forest is what is being worked, and a project shut over it says only that it exists.
Everything beneath a project is under its one line. Graeme: "everything beneath a project should be below the single, top-level node, including trees with no live agent and unattributed panes that are clearly in a path belonging to the project. at the moment i have to look in 3 different places to see everything". So after a project's roots come two lines of the project's own, each drawn only where it holds something: the trees the live-agent filter is holding back, shut, and the panes working in the project's paths that no bead claims, open. Three places became one, and a reader folds the project shut over all of it at once.
▾ summit-works ✓ 9s ago 8/21 3 agents ⚠ 3
├── ◐ smt-4kd3p DMS → noctalia v5 8/21 ◍ shell selector · working
│ ├── ● .20 wallpaper timer calls dms ◍ rebuilt generation 541 · working
│ ├── ◐ .1 wire the niri theme include ◍ wCM:p6 · idle · inferred, not confirmed
│ │ ├── ○ .4 restore app theming 1/4
│ │ │ ├── ○ .8 make the switch permanent
│ │ │ │ └── ○ .9 confirm quickshell wedges gone
│ │ │ └── ✓ .5 retire the DMS remnants
│ │ └── ○ .17 apply the two niri settings
│ ├┄┄ ◐ .16 guard a key in both layers ⚠ claimed · no pane
│ ├─▸ ✓ .3 land the session shell 2/9 3 unfinished beads beneath this
│ └─▸ ✓ 13 more beads · finished, and nobody on them
├─▸ 4 trees with no live agent
└── ⚠ 2 unattributed panes
├── ◍ wCM:pD waiting at a prompt smt-4kd3p.5 · asleep: waiting on switch /tmp/bdi-ground/summit-works
└── ◍ wCM:pE idle /tmp/bdi-ground/summit-works
▾ meadow ⠋ 1m ago 2/7 1 agent
├─▸ ◐ mdw-6qzt4 heartbeat cadence 2/7 ◍ pinning the cadence · idle
└── ⚠ mdw-2f8c the tracker refused the credential it was given
▾ ⚠ 1 pane in a directory no configured project covers
└── ◍ wCM:pF idle /srv/spike
────────────────────────────────── wCM:p9 ──────────────────────────────────
· rebuilt .#larkspur, generation 541
⚠ no herdr session · which agents are alive is unknown Enter show a all ? keys q quit
(The last line shows a notice and the keys together for the sake of the example; a screen with a herdr session carries no such notice.)
The cells of a bead's row, and their order, are the reader's, written as
three lists in [row] — the identity, the title and the state, which are the
row's three blocks. The default is the row as it has always been drawn:
glyph and id; title and badges; progress, agent and anomalies.
One badge is named on its own as badge.<key> and goes where it is written,
and badges is every badge the row does not name. A cell bdi cannot draw is
refused at read rather than drawn blank.
The prefix is two columns, then four a level of depth, and a line resting
shut says so inside its own elbow — ├─▸ , └─▸ , or ├┄▸ for a blocker —
so the fold state costs no width and every line at a depth starts in the same
column. A project line at depth zero carries the bare two-column fold marker,
▾ or ▸ ; a group line the same; a root is an ordinary row on the six-column
elbow of any first-level child. A shut marker in the arm is the whole of the
convention — a project, a group and a shut node say so; an open node says it by
drawing its children, and spending a marker on it would only cost the row
width. A root whose tracker never answered has no row to hang anything on, so
it draws its reason where its row would have been and carries no fold.
The glyph is the bead's own status and nothing else, glyph for glyph with
the legend at the foot of bd list's own output: ○ open, ◐ in_progress,
● blocked, ✓ closed, ❄ deferred, ◇ hooked, ⊙ pinned, and ? for a status bd has no legend
for — which the row then quotes, a status bdi does not recognise: “…”,
rather than swallowing. Liveness has its own cell, and one glyph meaning both
would make neither readable. The colours are bd's own 24-bit values for the
glyph, literal because bd's are and do not move with the terminal's theme
(read off bd 1.2.2); open gets no colour at all, because bd sends no
escape for it and inheriting is what lets a row's own brightness reach its
glyph.
An id is shown as what it adds to its parent's id, the parent being the
bead it is drawn under: .20 for smt-4kd3p.20 drawn under smt-4kd3p. It is
kept whole where its parent's id followed by a dot is not the front of its own
— the orphaned-dependency and re-parented nodes — because a bare suffix would place it
under a parent it does not belong to, and kept whole with no parent — a tree's
root, or the bead a rooted forest starts at — because there is nothing to
measure it against. The rule holds at every depth, so reading down from a
tree's root to a node and joining what each bead on the way says gives the
whole id back, which is what makes a column of them readable. It is drawn in
the colour of the glyph beside it, so a status arrives as a block a reader
finds rather than as the one column a glyph occupies; an open bead's id goes
without a colour for the same reason its glyph does.
A ground and two tiers say how live a row is, which is the one thing
about a bead bd list has no way to know and so the one thing this scale is
spent on. The ordinary row — nobody on it, still going — is the ground: the
terminal's own foreground, untreated, which is what most of the forest is
most of the time. An agent on it steps up from there by a weight; finished
and unworked steps down to the theme's colour 8, and a run steps down with
the rows it stands for.
The ground is the terminal's default because a theme's default is already the
brightest thing on its page and nothing can sit above it: color7 and
color15 are near-white in most themes, light ones included, so a tier
painted either of those is a tier painted like the ground on half the field.
The step up is therefore a weight rather than a colour — a font weight is the
one treatment here whose size is the reader's font rather than the reader's
theme, because the brightening a terminal does on bold is a remap of palette
slots 0-7 and the default foreground has no slot to remap.
Colour buys the scale one interval and no more, which is why there are two
tiers and not three. No theme reliably sets a slot between its foreground
and its colour 8, and none under colour 8 that a reader could still make out,
so a third tone would be a third value the theme picked without reference to
the other two — and a tone bdi pins itself instead is worse, because only
one side of the pair then moves when the theme does and the interval is
nobody's property at all. Colour 8 is the rung every theme sets and few rows
otherwise use.
Finished here means what it means to a run — closed, no agent, no anomaly —
so a closed bead whose pane is still alive keeps its tier, because that is
exactly the row worth looking at. The box-drawing is held on the ground while
the row around it steps off it: it says how the tree is shaped, not how a
bead is going, and bd leaves its own tree prefix undimmed on a closed row
too. Holding it there takes saying the weight it does not take as well as the
colour it does, because a tone is drawn under the whole row and a weight in
one composes with a weight in the other where a colour replaces it. Colour is
never the only channel: the glyph says the status and the words say the rest,
so a terminal with no colour loses nothing.
Priority and issue type are a badge's to draw, and never a hue. A row
has exactly two colour-carrying channels — the glyph carries the status,
matching bd, and the row's own text carries how live it is — and priority as
a hue wants the second one. The two cannot share it: a P1 bead with an agent on
it would be either bd's orange or the terminal's default, and whichever won,
the other fact would be gone. Liveness is the one only bdi can draw. So a
setup that wants either on the row writes a [[badges]] entry over priority
or issue_type, and [row] says where it goes.
The state block, right-aligned, in this order: the fraction where the line stands for more than itself; the agent; the anomalies; then, on a line shut over such things, ◍ 2 agents beneath and ⚠ 1 bead beneath; then the notes — a subtree the tracker stopped at, unfinished work the line is shut over, a status outside bd's set. A row's own two come before the counts because those name one bead and these count several: a number met before the name it belongs beside reads as the total the name is an example of.
The agent cell says what the agent is doing, not which pane it sits in:
◍ <caption, else pane id> · <state> · <join caveat, where inferred>
The caption is the pane's state label for the state it is in, falling back to
its title — not display_agent, which seats set to their own bead id and which
would repeat the row two columns to its left. herdr shows a pane id nowhere a
reader can look one up (only in its socket API and its JSON), so an id here
spends the row's widest cell on a handle nobody can follow. A pane carrying
neither label nor title keeps its id; there it is not noise, it is the only
thing left telling one live agent from another. The separator is the row's own
·: a pane id was a token and parsed on sight, but a caption is free text and
waiting at a prompt is three words, so run together they would read as one
sentence. The state stays between the caption and the caveat, because the
caveat says how bdi knows which pane this is — inferred, not confirmed,
where only the pane's display_agent named the bead — and against the caption
it would read as doubt about the work. The pane id is demoted, not gone: it
still names the tail's own rule, where the reader has already chosen one pane,
and every conflict carries it in its own fields. herdr's state words are read
verbatim except blocked, which is a TTY prompt waiting and one of three
things this tool calls blocked, so it is said in full: waiting at a prompt.
A line that stands for more than itself says how much of that is done — the fraction rule in Tree construction. A shut closed line over unfinished work says how many beads, in words, beside the fraction saying it in arithmetic; a shut line over agents or anomalies counts them.
The band under the forest is a rule with the selected pane's id centred in it, over that pane's output indented two columns; the newest lines are the ones kept, because a pane's last line is what it is doing now, and the newest sits on the band's last row, as it sits at the foot of the pane. The rule is drawn whether or not there is a pane, so the band never goes blank and always says where the forest stopped. Where there is no pane, the reason sits under the rule in dim, and there are six: no agent provider at all; a provider that would not answer; the selection is not a bead (a project line, a group, or a thing in one); nobody is working this bead; the pane has gone; the pane is too busy to be read. While a pane is being read and has not answered, the band says so rather than staying quiet.
The rows are drawn in the colour and attributes the pane gave them. The read
asks herdr for its ansi form, which on a measured session carries nothing
but SGR sequences — no cursor motion, no erasing — and the view folds those
into styles as it draws. A fold and not a terminal emulator: a control
sequence that is not an SGR is dropped whole, and an SGR parameter the fold
does not know is skipped rather than refused. The rows arrive wrapped at the
pane's own width, so the band shows a clipped view of a wider pane, each row
cut with … where it runs past the band; there is no reflow, because a pane's
screen is a rectangle at its own geometry. What bdi says in the band is
toned apart from what the pane says, which is the whole of what tells its words
from the pane's; which tone that is depends on the reader's background, below.
An agent's own screen often ends in rows that are the same whatever it is
doing. Claude Code's are its input box, its model and context meter, and its
mode line, so a tail of its bottom rows says nothing about the work. In the
tail, the suggestion it greys into the box reads as if the reader had typed
it. So the reader can say where the tail cuts each agent's screen.
[tail.crop] maps the name herdr gives the agent in a pane, its agent
field, to a crop bdi ships. The tail then shows the rows above the cut,
newest at the bottom. A crop is chosen by name rather than spelled out in the
config, because what an agent draws at the foot of its screen is that agent's
layout. The model knows no agent, and the strategy is the one place that
knowledge lives. One ships:
| crop | where it cuts |
|---|---|
claude-code |
at the top of the two rules that hold Claude Code's input box, counted up from the foot of the screen |
A rule counts only where it starts in the first column, because a rule
indented into a message is part of the message. A cropped pane is read whole,
with --lines past anything a screen holds, because the cut is found by
looking at the screen. A screen the crop finds nowhere to cut is tailed as it
was read, because a band cropped to nothing reads as a pane with nothing to
say. A pane whose agent has no crop is tailed uncropped.
The pane is read on the band's own clock and not on the trackers'. herdr
answers a read in a few milliseconds, and no herdr event carries a pane's
content — pane.output_matched is one-shot, the primitive behind
pane wait-output rather than a stream — so polling is the mechanism. The
band asks for the pane again [tui] tail_refresh_millis after each answer
lands, 250 by default: four a second is where a reader stops telling the band
from the pane, and each read is one herdr process. A gap after the answer
rather than a period, as refresh_seconds is, so a slow herdr stretches the
gap rather than piling asks up behind itself. The rows stand until the next
answer lands, and an answer that repeats them changes nothing on the screen.
A collection landing under the same pane leaves the band alone; what it can
do to the band is move the selection off the pane.
Reads use herdr agent read --source visible: every agent worth tailing is
alternate-screen and working, and herdr refuses recent for those, naming
visible as the way through. Nothing the reader is sitting in front of waits
on the read: asking is a send to herdr's own threads — one runs herdr and
blocks in it for as long as herdr takes, the other waits two seconds for that
answer and gives up on it — and the answer arrives later on the loop's channel,
carrying the pane it is about, so an answer about a pane the reader has since
left is dropped. That is what keeps q and ^C answered while a read is
still outstanding. The two seconds bound what the band says, not the loop. One
question at a time: a question that outstays its welcome is not abandoned but
its answer is thrown away when it finally arrives, and none is asked while one
is still out, so a wedged herdr costs one waiting thread rather than one per
poll.
f focuses the selected pane in herdr — the only write bdi makes to herdr,
which is not a system of record. On a row with no pane
it is a no-op, not an error: there is nothing to focus and nothing has gone
wrong. On a pane that will not come, the tail says so where the tail is. From
the bead view, Enter does the same — see The bead.
y puts the selected bead's id on the clipboard — the id alone, exactly as
bd takes it — and the foot says copied bdi-2bb.42 until the reader's next
key or click, because nothing else on the screen changes for it. It is written
with OSC 52, the terminal's own escape sequence for a clipboard write, and with
nothing else: the sequence travels through herdr and ssh the way the rest of
bdi's output does and needs no program outside it, where wl-copy or xclip
would be a new one with a new seam. A terminal that does not honour OSC 52
drops the sequence, so there the key does nothing — and the foot still says
copied, because bdi cannot tell. That is the degrade this accepts. On a row
that is not a bead — a project's line, a group's line — y does nothing and
says nothing, as Enter does. A hidden tree's root is a root, so both keys
work there as they do on any other.
The band takes the rows the forest leaves free, up to half the screen's height, so a short tree leaves no blank between itself and the pane. Where the forest needs the rows, the band keeps six lines under its rule, and on a short screen it yields those before the forest yields any: the forest is the thing this tool exists to show. A read of an uncropped pane asks herdr for as many lines as the band had room for on the last frame, so a band that grows fills on the next read. A cropped pane is read whole whatever the band's height, and the rows above its cut fill the band from the bottom, so a taller band shows more of what the agent last said.
t hides the band and gives its rows to the forest, and t again brings it
back. While the band is hidden bdi reads no pane, since nothing would be
drawn from the read: a read already out is answered into nothing, and none is
due. Bringing the band back reads the selected pane at once, and until that
answer lands the band says it is reading rather than show rows as old as the
hiding. The band is shown at every start. The choice lasts the run and is not
remembered.
bdi cannot see the reader's background and does not ask. A [theme] section
carries it — background = "dark" or "light", absent meaning dark. A value
that is neither is refused rather than read as the default, because a typo
answered silently is the failure the key exists to remove: the reader has said
which background they are on and has nothing on screen to tell them they were
not heard.
[theme]
background = "light"One thing on the screen reads it, and that is the band's own voice. Every
other tone bdi draws is the terminal's own foreground, one of its sixteen
slots, or one of bd's absolute literals. The reader's theme resolves the first
two against whatever background it has; the third is fixed on purpose, so that a
status is the colour in bdi that it is in bd. None of the three has a light
form to choose. The band's voice is the one treatment bdi composes itself, and
the terminal resolves it against the background rather than against the palette:
dim over the default foreground is GIT_COLOR_FAINT_DEFAULT's composition, and
the terminals that implement dim by scaling the foreground toward black leave it
darker than plain text on a light background, so the one distinction the band
rests on runs backwards. A dark background is answered with the attribute, a
light one at colour 8.
Two costs a reader should be told rather than left to find. A light
background with NO_COLOR set leaves the band no tone at all, because the
channel that survives colour being off is the channel a light background
inverts; there the rule and which of the band's states it is in are what is
left. And a light reader who never sets the key gets the dark palette, and so
gets the inverted band — which is the price of not detecting, and is why the
default is documented as a guess rather than presented as a reading of the
terminal.
The key is read again whenever the reader writes it. The check that carries a reload hands the whole config over rather than a verdict about it, and the screen reads what it draws with out of that in one place — so the band is on the background the file names as soon as the next frame is drawn, and a key added beside it inherits the same wiring rather than the omission.
That the screen reads it in one place is the point, and it is worth saying
because the alternative is what was here before. A reload that reached the
collector and not the view left every view-side setting stranded by
construction rather than by an omission at any one key: the count grew as keys
were added, and nobody decided it should. [theme] background was the instance
that named the shape, and [tui] tail_refresh_millis was the other one
standing at the time.
Why the reader says rather than bdi asking. A terminal query degrades to a
wait or to a confident wrong answer, and a wrong query answer is intermittent —
right in one terminal and wrong in another, right outside a multiplexer and
wrong inside it — so nobody can see what is producing it. A declaration is wrong
the same way on every terminal from the first frame, which is what makes it
something the reader notices and one documented line fixes for good.
docs/visual-language.md §4 carries the evidence, including what two years of
detection cost delta.
Enter on a bead row shows that bead whole, as bd show would: a head naming
the bead and saying everything its row says; then, under the names bd show
prints and in its order, the description, the notes, the parent, what it
depends on and what it blocks — each related bead with its glyph, id and title,
and one the tracker's answer no longer holds named by its id alone and said to
be not in the tracker's answer. A section the bead has nothing in is left
out, as bd show leaves it out. Everything drawn comes from the rows bdi
already holds: the description and the notes are in the bd list --json rows,
and the related beads' statuses and titles, with the reverse edge that says
what a bead blocks, are read once over the whole answer when a project is
collected. No key costs a call to bd.
The bead window is a drawer against the right edge of the screen: the full height above the foot row whatever the bead's own height, at least eighty-two columns inside its border, and four fifths of the screen where the screen has more than that. What stays visible to its left is the forest's own spine, its glyphs and its ids, with the selected row still reversed among them, so the reader keeps their place and is shown no cut title. The tail band goes under the drawer as the forest does. The forest beside it is not dimmed: dim means nobody is on a row, on every surface.
It is a window over the forest, like the key bindings, rather than a screen in place of it: the row it was opened from is untouched beneath it, so leaving the view puts the reader back on the same row with the forest exactly as they left it, and a collection landing behind it refreshes the forest and leaves the view up — unless it moved the selection off the bead, because the bead closed into a run or left the tracker, in which case the view goes back to the forest rather than show the forebear the selection fell to under the title the reader opened.
Inside the bead window, top to bottom: a blank row; the head; then bd show's
sections, under bd show's names and in its order.
The head is the forest row unfolded — every cell the row draws, at its whole width, one row each, and beside them the facts the row has no width to carry. In this order:
- the glyph, the id, and the title
- the labels, on a row of their own, so that however many a bead has they never push its name off the end of the row above
- the status word, the priority, the type, the owner and the assignee, by name
as
bd showprints them ready, orblocked by:and the blockers, asbd readyandbd listsay them; a bead that is neither, such as a deferred or closed one, gets no row- the created, updated, started and closed dates, as
bd showprints them - the agent, with its state and its join caveat, as the forest's agent cell says them
- each anomaly, on a row of its own
- the badges, with their links, separated as the forest separates them
- the progress fraction, where the row has one
The head is built from the cells the forest row is built from, so the two cannot disagree about a bead: one place decides a cell's words, and the row and the head are two widths of it rather than two renderings.
Every row sits one column in from each side of the border. The floor is
eighty-two rather than eighty so that the margin is paid for by the frame: the
prose still gets the eighty columns bd show wraps its own at.
The bead window at its floor:
┌ smt-4kd3p.1 ─────────────────────────────────────────────────────────────────────┐
│ │
│ ◐ smt-4kd3p.1 wire the niri theme include │
│ │
│ in_progress · P2 · task · Mira Vance │
│ created 2026-08-30 · updated 2026-09-02 │
│ ◍ wCM:p6 · idle · inferred, not confirmed │
│ │
│ DESCRIPTION │
│ │
│ The niri config still sources the theme file the old generation wrote, │
│ so a rebuild puts its colours back and the session draws two themes at │
│ once until the include is repointed. │
│ │
│ PARENT │
│ │
│ ↑ ◐ smt-4kd3p DMS → noctalia v5 │
│ │
└──────────────────────────────────────────────────────────────────────────────────┘
The description and the notes are rendered as markdown, in the bead window's
own styling rather than the forest's: a heading bold and clear of the prose, an
item behind bd's own bullet and hanging under its text, a code span or block
in a tone of its own, emphasis italic and strong emphasis bold, a quote barred
down its side, and a link followed by where it goes. Prose reflows to the
window. A soft line break in the source is a space, as CommonMark reads it,
so a paragraph fills the width it is given; a hard break, a list item, a code
block and a table keep their rows. The window is never narrower than the width
an author wraps for, so nothing reads worse than its source. Text that is
broken markdown is drawn as written: a renderer that drops text is worse than
none.
The line naming the bead wraps too: this is the one place a reader has asked
for that bead in full, and the reason a forest row is cut — a forest is a
column of rows that has to line up, and its selection's geometry is one row per
bead — holds nowhere here, over one bead drawn at its own height with nothing
lining up against it. A name that takes more than one row hangs under where its
title starts and is toned as the head the whole way down, so it reads as one
block rather than as a title and a stray. Every other row — a related bead's —
is cut the way a row of the forest is. A window the bead's glyph and id already
fill across, or one with no rows for a wrapped name to take without filling it,
cuts the title on its own row instead: a name that says nothing on any of its
rows, or that fills the window on its own with the status and the prose below
the foot of it, has taken the page from the reader to say what the reader
already knew. A title the window has room for is drawn as its author wrote it,
runs of spaces and all: breaking a line across rows is what closes those up,
and a window that closed one up without having to would be the one place on the
screen saying something the forest row beside it and bd show both say
differently.
The border title carries the id, and how far down the bead the reader is
where the bead is taller than the window. There the motion keys move the bead
rather than the selection: j, k and the arrows a row, ^D and ^U half
the window, g and G to either end, and the wheel wheel_notch_lines a
notch, which is what it moves over the forest as well.
The bead window's keys go on the foot row while it is up, where the
forest's own keys are the rest of the time. The row keeps the forty-column rule
of Keys: Esc first, four keys, and the rest behind ?. Esc is first
because a reader who cannot see how to leave is stuck in a view they may have
opened by accident.
The four are Esc back ? keys Tab related y id, and the order ranks
them because it is also the order the row gives them up in. After the way out
and the way to the rest, the two that are left go to what a reader of this
window cannot work out for themselves. Nothing on the page says the beads it
names can be stepped through, so Tab is the whole of that; and y works on
every bead, where f does nothing on one with no pane, and taking the id away
is what a reader opens a bead to do.
Enter is off the row for the reason Keys gives for taking it off the
forest's: pressing Enter on the thing under the cursor is what a reader of any
list does anyway, and Tab related is what puts a thing under the cursor. The
motion keys are off it for the reason the arrows are off the forest's, and how
far down the bead the reader has got is the border title's to say. q is a
second way out beside Esc, which a row of four cannot afford.
This row gives its keys up from the end rather than whole, and Esc back
is the last of them to go. Words being given up before facts are, and from the
end, is what the foot already does with a notice's own words.
Keys yield before any notice was written for the forest, where a reader is held nowhere: a key they can rediscover by looking costs less than a fact they would never learn. Under a window holding four fifths of the screen there is nothing to look at, and a notice wide enough to take the row leaves no way out anywhere on the screen. So the forest's row still goes whole or not at all while the window's narrows, and the foot is never told which of them it has: a row reaches it as the forms its caller will stand behind, fullest first, and the forest's is one form.
The view is the hub. From it, Enter and f focus the bead's pane in herdr,
y copies its id, and the view stays up; Esc goes back to the forest, and
so does q, as it does from the bindings, so the forest a reader was looking
at is still there to quit from. Back rather than close, because closing is
what bd close
does to a bead and this does nothing to one. ? puts the bindings up over the
forest, ^R collects behind the view, and every other key does nothing there.
Enter on a row that is not a bead — a project's line, a group, a thing in
one, a root whose tree would not read — has nothing to show, and does nothing
and says nothing, as before.
Until this view, Enter focused the pane. Graeme moved focus to f (15:05
BST 2026-09-02) so that Enter shows the bead and focus stays one key from
the row for anyone who learns f; the forty-column rule below is why f is
in ? and not on the keys row.
Five kinds, and two of them are a project's own. Under each project's line,
after its roots, in this order: the trees the live-agent filter is holding
back, then the panes working in the project's paths that no bead claims. Below
the trees, in this order — severity first: projects whose tracker could not be
read at all, panes in directories no configured project covers, conflicts
nothing could settle. Those three have no project line to hang under: a failed
project has no line of its own, a pane in no configured project has no project,
and a conflict can reach across two. An empty group draws nothing. Each line
carries its count, so folding a group never loses what it holds, and each opens
to name its members: a failed project with its reason, a conflict in full, a
hidden tree as its root's row with its tree beneath, a pane by id and state
with the directory it is working in — the directory being what both pane groups
are asking the reader to look at, one to place the agent and the other to
configure the project. A project's group is known by its kind and its project,
so a fold on one project's quiet trees is not a fold on another's. An
unattributed pane's row also says what the pane reported about itself, ahead
of the directory: its display_agent, then its caption by the rule the agent
cell uses, the row's · between them and either half left out where the pane
did not report it. No bead's row will say these for a loose pane, so its own
row does; a pane that reported nothing keeps the row it had. The title block
is cut from the right, so a narrow row gives up the directory first and the
pane's id and state last.
An unattributed pane whose claim was refused says so on its own row, in the
state block at the right: a claim on this pane was refused. A pane nothing
claims and a pane whose claim bdi read and would not honour are opposites,
and the group's own line says the same thing about both — so a reader who is
at the pane, which is long before they are at the conflicts, reaches for the
one explanation the tracker rules out. The sentence says only that a claim was
refused; which disagreement refused it is spelled out in full among the
conflicts, and a row that re-told it would spend the width the directory is
drawn in. It sits in the state block rather than the title so that it outlives
the directory on a narrow row: the directory is for placing a seat, and a
reader who has taken this pane for one nobody claimed is not placing a seat.
bdi does not distinguish a seat that has not registered yet from one that
finished and cleared its key — neither is claimed by anything, and nothing it
reads says which.
The groups rest by the same rule as the trees. A count is not a view of what it
holds, so a group over live panes — unconfigured, conflicts, unattributed —
rests open; a group that reports on the reading rather than on work in flight —
failed projects, hidden trees — rests shut, and hidden trees in particular
holds trees hidden because nothing live is in them, so opening it would
contradict the rule it exists to serve. Hidden trees is a group nothing went
wrong in — the filter put them there and a key takes them back out — so it is
drawn without a warning. It does not name that key: a project's line says only
what is so of that project, and a key that acts on the whole screen is offered
once, among the foot's keys. The roots Shift+F puts out of the way are the
other group read that way. A group that said only how many trees it hides
would read as "nothing to see here" while hiding broken ones, so it also says
how many of them carry findings: 4 trees with no live agent · 1 with
findings. The findings stay hidden — the reader asked for that — but the group
admits they exist.
A hidden tree is a tree, and the group is only where the filter put it. The
tree is still in hand — the filter is a display choice over what was collected,
not a second reading — so inside the group each hidden tree is drawn exactly as
its project would draw it, one level further in: the same root row, with its
glyph, its fraction and what it is shut over; the same findings under it; and
the same answers to every key, so Enter shows the root and y copies its id.
The one thing that differs is where it rests. Graeme: "the top-level trees
with no live agent should not be expanded by default". So a hidden tree's root
rests shut whatever is beneath it, where the same tree shown under its project
rests open onto the work a reader could start; a fold the reader opens on it is
theirs, and survives a refresh and a alike.
Per-tree findings are not groups: a tree's orphaned-dependency beads, its cycles and the nodes the tracker stopped at are drawn as note lines directly under its root's row, whether that root is folded or not, so folding the root cannot lose one.
Every loose pane is on screen exactly once, under its own project. A pane in a project's paths that no bead claims is the project's whether or not its roots read, so a project whose tracker could not be read at all still draws its line where such panes are working in it, with the failure reported where it always was, in the group below the trees.
a toggles between the trees with a live agent and every tree: it moves a
project's quiet trees out from behind their line to sit under the project as
the trees with an agent do, and back. It is a state of the view, not a fold: a
refresh carries it onto the new snapshot exactly as it carries the folds and
the cursor, so the trees a reader brought out by pressing a do not go back
behind their line thirty seconds later.
It was doing so — the filter lived on the snapshot a refresh replaced wholesale
— and because the group's own line is the only place on the screen that names
a key, losing the answer to it read as the group shutting itself. Carrying it
cannot resurrect a filter herdr has made meaningless, because with no herdr
there is no filter to apply and every tree renders.
The row under the tail names the handful of bindings worth a permanent line,
each by a key a reader can press — a all ? keys / find q quit,
forty columns, which is what a forty-column terminal holds without
losing its last words, and the last words are q quit. f focus would
overrun that, so f lives in ? and not on the row. ?
opens the full table in a window over the forest; any key closes it. The
window is the table's own size, so a terminal with rows to spare gets a window
and one without gets the screen: the table grows by a row per key, and a
ceiling short of the terminal would free rows off the top of the forest —
which is not the row the reader opened ? from — and pay for them by hiding a
binding from the one view that says which keys exist. ^R
came off the row to
make room for ?: refresh is the most skippable, since bdi
collects on a timer and on change reports anyway, so ^R only ever means
now, and ? is one key from the full list.
The row is full, so a key earns a place on it by being worth a column on
every screen. n is not: it means nothing until a search has been made,
and / find beside it already says searching is there. So n and N live
in ?.
The bindings are vim-like, with the arrows as aliases:
| key | does |
|---|---|
Enter |
show the selected bead, or focus its pane from the bead view |
f |
focus the selected bead's pane |
Space |
fold or unfold the selected node |
a |
show every tree, not only those with a live agent |
? |
show these key bindings |
s |
cycle which copy of a bead opens, under the selected node |
S |
cycle which copy of a bead opens, across the whole forest |
F |
draw the selected bead as the only root, or put the forest back |
t |
hide or show the pane's output under the forest |
/ |
find part of a bead's id or title, wherever the forest draws it |
n, ^G |
go to the next bead matching the search |
N, ^T |
go to the one before it |
q, ^C |
quit |
Esc |
go back to the forest from the bead view |
Tab |
move to the next bead the shown bead names; Enter follows it |
^R |
collect from the trackers again now |
e |
expand the selected node and everything under it |
E |
expand the whole forest |
c |
collapse the selected node and everything under it |
C |
collapse the whole forest |
d |
restore the default folds under the selected node |
D |
restore the default folds across the whole forest |
y |
copy the selected bead's id to the clipboard |
Down, j |
move down one row |
Up, k |
move up one row |
Right, l |
expand, or move to the first child when it is already expanded |
Left, h |
collapse, or move to the parent when it is already collapsed |
^D, PgDn |
move down half a screen |
^U, PgUp |
move up half a screen |
Home, g |
move to the first row |
End, G |
move to the last row |
The mapping, the ? window and the row under the tail are one table read
three ways, so a key is written down once and nothing on screen can disagree
with what pressing it does; a build-time check refuses an action no key
reaches. The table is ordered least guessable first, not naturally, because a
screen too short for the whole of it shows the top: ordered naturally, an
eight-row window gave a reader six motion keys and 8 more — the arrows, which
they would have pressed anyway. Ordered this way they get Enter, f,
Space, a, ? and a count. A reader who cannot see the arrows presses one
regardless; one who cannot see a never works out that the trees they are
missing are being filtered. A window too short for every binding counts the
ones it left off rather than stopping, and the title — press any key to
close — is the line that survives every cut, because a reader who cannot see
how to leave is stuck in a view they may have opened by accident.
h and l carry two meanings each because that is what a tree makes natural
and what every vim-flavoured file tree does; h always meaning "parent" would
strand a reader on a collapsed node with no way to open it from the home row.
The fold keys are single keys rather than vim's zR, zM and zx: a
prefix is a mode, and bdi has nowhere to say it is in one — vim puts the
pending command in its last line, and bdi's equivalent is the keys row,
which has no spare column. Case says the scope instead: e, c and d act
on the selected node and everything under it, and E, C and D on the
whole forest. c and C are the one place a reader may knowingly fold over
a live agent, and d and D bring it back: restoring the default recomputes
the spine to live work from the snapshot in hand rather than replaying a
stored fold set, so it stays right after a refresh has changed who is working.
d spends only the hand folds on the selected node and its descendants, so a
reader who has opened and shut their way through one branch can put it back
without giving up their folds in every other tree.
/ opens a prompt at the foot, and the search moves as the reader types, as
vim's does with incsearch. A bead matches when its id or its title holds that text, letter case aside — part
of either, not the whole of one. That is what the reader has: the forest row
draws a shortened id and view::row::abbreviate is the only thing in bdi
that draws one, so on a long screen it is the only spelling of a bead they
have ever been shown, and a title is prose they are quoting a word out of.
So the answer is a set, and the forest is the set. Every match is already a
row, so there is no result list to build — and building one would throw away
the thing a row carries that a list cannot, which is the bead's place in the
tree. n steps to the next match and N to the one before, coming round at
either end.
Each edit at the prompt is the whole search again. It lands on the
first match after where the selection stood when / was pressed, as n
would step from there, and not after wherever the last keystroke landed. So
taking a character back widens the search from the same place it began. A
keystroke that matches nothing, or leaves the prompt empty, puts the forest
back as it stood at /. ^G and ^T step to the next and previous match
while the prompt is up, as in vim, because n and N are letters of the
text there. Enter closes the prompt and leaves the selection on the match; it
never opens the bead. Esc puts the selection, the scroll and every fold back
exactly as they stood when / was pressed.
The prompt edits its text as a shell's line editor does. It keeps a cursor in the text, drawn as the terminal's own, and its keys are readline's, because a shell prompt is where a reader learnt what these keys do:
| key | does |
|---|---|
Left, Right |
move one character |
Home, ^A |
move to the start |
End, ^E |
move to the end |
BkSp |
delete the character before the cursor |
Delete |
delete the character at the cursor |
^W |
delete the word before the cursor, as far back as a space |
^U |
delete everything before the cursor |
^K |
delete everything from the cursor on |
An edit that changes the text is a keystroke like any other, wherever in the
text it is made. A key that only moves the cursor searches nothing. ^B and
^F are left out because the arrows are what a reader reaches for, and ^D
because it is half a screen in the forest and the end of input in a shell.
The ? window lists these after the forest's own keys, with Enter and Esc,
each said while searching.
Matches are numbered in the order the forest draws them. Not by relevance:
screen order is the order a reader scrolling would have met them, it is the
order place_of already takes a single jump in, and it puts the trees the
filter shows before the ones it hid. So the ordinal at the foot is a fact
about the forest rather than about the search — the same bead is the same
number however the reader reached it, and they can count it off the screen.
Where a search lands is the one thing a whole id decides. Type an id and the selection goes to that bead even where rows above it match too — a row merely titled after a bead must not shadow it, and that promise is older than the widening from an exact match. It changes the landing and not the numbering, so a deliberate id search can truthfully say 5 of 12.
The foot names the bead and the count on every landing: dunwich · dun-7.1 — 5 of 12 matching. It used to say nothing when a search landed cleanly,
because the selection was the whole answer. It is not any more — the reader
typed a fragment rather than a name, the id on the row they land on is the
shortened one, and no row can say that eleven others matched. One id in two
trackers is now two matches rather than one landing and a sentence about the
other, so the reader steps to the second and looks at it.
What is held between presses is the text, not the matches. A snapshot
refreshes on a timer, so a stored match set would be stale within the interval
and a stored place in one would be wrong the moment the reader moved by hand.
n asks which match is drawn after the selection — a question that is still
right after both.
What a search step opens is provisional. A step is n, N, or a
keystroke that changes the text, ^G or ^T at the prompt, and it opens whatever is folded over the bead it goes to.
The next step puts those folds back the way they were before opening what its
own match needs, so walking the matches leaves no trail of open branches, and
the branches the last match needed stay open. A fold the step found open is
put back open. A fold the reader shut stays theirs while a step has it open,
so live work arriving under it spends it as it would any other.
Any other act by the reader makes whatever is open at that moment theirs, and
no later step shuts it. That is a move by key or by click, opening or shutting
a fold, opening the bead window, and every other key but /, which only opens
the prompt, and the keys at the prompt, the Enter that closes it among them. A wheel notch is not an act here, because
it moves no fold and no selection.
What the last step before / opened is still provisional when Esc puts it
back, so the next step shuts it as it would have.
Control held down still moves a row: a key that does not ask for control
answers whatever modifiers are held, which is what the arrows and the letters
have always done, and only ^D, ^U, ^R, ^G, ^T and ^C ask for it, with
^A, ^E, ^W and ^K at the prompt. That is
inherited rather than chosen, and if it should change it is its own bead.
^R is a notification like any other: it takes the same window and the same
queue as a message on the inbound channel and the poll a project arms for
itself, and differs only in naming every project rather than one. It has no
path of its own, so nothing it does can be lost where the other two are kept.
The pointer works too. A click selects the row under it; a wheel notch moves the window over the forest and leaves the selection where the reader put it, even where that takes it off the screen. So the forest holds a viewport of its own, and the click reads it rather than guessing from the selection — a click after the wheel selects the row it lands on. A keyboard motion is the other half of that bargain: it brings the selection back into view by the least scroll it can, so a reader who wheeled somewhere and then stepped a row keeps what they were looking at. A click on the tail, on the key row, or on a blank row past the last line selects nothing, and neither does one on a row the keyboard cannot rest on — a note. Sliding to the neighbour would select something the reader did not point at.
How far a notch goes is [tui]'s wheel_notch_lines, defaulting to three,
and it has to be bdi's to say. A terminal scaling the wheel for its own
scrollback neutralises that scaling to its sign while a program is reading
mouse reports, so what arrives is one report per detent whatever the reader
set. Three is the terminal convention and suits a wheel mouse; a high-precision
trackpad reports once per cell of travel rather than per detent, and no one
number serves both.
Capture is on for the whole session, unconditionally, and that is a decision
with a cost. While bdi is up the terminal stops getting the mouse, so
dragging over the window no longer selects text in it — a real loss in a tool
whose job is showing bead ids and pane ids you then want to paste. It is taken
anyway, because the stack this is read in gives most of it straight back: herdr
owns the mouse above the pane and keeps its copy mode, which selects by
keyboard, and kitty maps shift-drag to plain text selection even while an
application has grabbed the mouse — starting a selection, and picking out a word
or a line, all still work under shift. What is actually given up is dragging to
select inside one pane, and shift-clicking to extend a selection already made,
which is the one gesture kitty leaves behind when an application grabs. There is
no setting for it: a flag would put the question to every reader when it has one
answer here, and the answer is a property of the terminal rather than of the
reader's taste.
Of everything capture then reports, only those two gestures are answered.
crossterm asks the terminal for any-event tracking, so it reports every cell the
pointer crosses whether a button is down or not. A release, a drag, bare motion,
the other two buttons and the horizontal wheel are each dropped on the thread
that reads them, before the loop can be handed one — a loop wedged by a flood is
a ^C that never reaches the Quit mapping and a terminal left in raw mode.
Each project's line says how fresh its own rows are, directly beside its name: a one-column mark, then how long ago the rows were read. It is per project because each project is read on its own clock — a refresh naming one project re-reads that one and leaves every other's rows exactly as they were — and an indicator that spoke for the whole screen had to quote the oldest read to stay true of every row. That under-promise was the cost of the position, not a rule worth keeping; beside the name it speaks for that project's rows alone, so it is exact.
The mark is ⠋ turning while a read of the project is outstanding — ten
braille frames at 80 ms, cut from the wall clock rather than counted so every
redraw inside one collection agrees which frame it is (a keystroke redraws the
screen too, and a counter would make the mark jump for it); ⠿, the turning
mark held still, when the read has been outstanding longer than one may be and
has produced nothing (unanswered); ✓ when the last collection read every
root; ⚠ when it met a root it could not read, resolved to the worse where a
project's roots disagree, because the rows under the name are then short of
that root's and a project folded shut draws no other line saying so. One
column in every state, so the cell does not change width for a read starting
or ending.
The age is a duration, not a time of day — 9s ago, 1m ago, 1h ago,
1d ago — in one unit, the coarsest that still says it. The reader's question
is how much to trust the rows and they answer it from the order of magnitude;
a wall clock made them subtract one time from another. It is said while a
collection runs as well as at rest, because the rows on the screen during a
collection are the previous collection's rows and this is the only thing that
says so. Nothing at all before the first collection of a project comes back:
there is no read to date the rows to, and no rows either. A read that failed
counts as a read — its trees went down with the tracker that refused, so none
of its rows are on screen to be stale — and so does a read the probe found
nothing to do for.
The cell goes in the line's title block, so it is the first thing a narrowing line gives up, and it is given up whole rather than cut — half an age names no duration. At forty columns the project line is what it always was.
The mark turns because the loop gives itself a deadline, and so do the
ages. A collection reports nothing until it is done, and a bdi whose
projects are all reported over the inbound channel polls nothing, so an age
left alone would say 0s ago for as long as the reader left it. So the loop
waits with a deadline for exactly as long as what is drawn is going to stop
being true — one frame while a collection runs, otherwise the next boundary of
the newest read's own unit — and with no deadline at all where nothing on the
screen can go stale. Both halves are cut from a clock by dividing it, so both
hold to that clock's own boundary rather than for a whole unit from whenever
they were last drawn. That deadline is measured from the instant the frame was
drawn at, with one instant handed to both the paint and the wait; two reads of
the clock straddling the moment a read became unanswered once left the loop
waiting with no deadline at all.
Startup draws the forest first. Every configured project is on the screen
with its mark turning before any tracker has answered — measured at cfcbd80
on the same pty harness and the same three projects: 8.2 s to the first byte
before, 22 ms after — and each fills in as its own collection returns. The
projects come from the config, so the first frame has real content to draw.
The foot is one row: the keys, and every notice the view carries. A notice
is a fact that has no row to sit on. An agent provider that could not be
reached empties the agent column on every row; a bdi whose inbound socket
would not open is told nothing when a project changes, so the whole view is
only as fresh as the refresh interval; and a bdi that could not run git at
all named its one project after a directory rather than after a remote, which
is half of every key on the screen and wrong on no row in particular. None of
them has a row that is wrong, which is why none can be said anywhere else. A
provider nobody
installed is not a third: nothing was lost, so there is nothing to say, and
the tail band carries what little there is to carry. The rule's other edge
is that a per-project fact never belongs there: it has a project line, and the
line is where the reader is already looking at the thing it is about — a
tracker that refused a credential is drawn on its own project, and freshness
moved off the foot for the same reason once it became per project. The foot is
one line, so a per-project fact there is a fact that evicts other facts.
The order is by consequence, and the foot gives up the last first. The keys yield before any notice, because a key can be rediscovered and a fact silently absent from the one row nothing can fold or scroll away is a fact the reader never learns. Among the notices, a herdr nobody can reach comes first, because the agent column is what the reader came for. Where the screen is too narrow even for the notices in full, words are given up before facts, and from the end: each phrase has a brief form — agents unknown, polled, not reported, another bdi had it, name guessed · set BDI_PROJECT — and the notice the foot puts first keeps its full phrase longest. Being cut is the one thing a notice must not be: the mark a cut leaves is the mark any long line gets, so a severed warning reads as a sentence that ran out of room rather than as a fact the reader has lost.
A notice reaches the screen by the same road whether a collection produced it (herdr unreachable, read off the snapshot behind each frame) or this process did, once at startup (the socket). The status bar is handed a list in the order it should give them up, so the next fact of this kind needs no new path to the screen.
How a notice leaves the foot follows from how it reached it. One read off the snapshot behind each frame is gone the frame after it stops being true, and a config that will not reload is looked at every couple of seconds and comes off when the file mends. One this process handed over once, before the first collection, is rechecked by nothing, so a clock is the only thing that can take it off, and it stands for a minute. The rule is the road rather than a list of notices, so the next fact that arrives that way inherits the minute for arriving that way.
bv (beads_viewer) — a mature Go TUI for beads with a list/detail split, a
kanban board, a dependency view, a multi-repo workspace mode and
PageRank/critical-path insights. Ruled out on three grounds.
It cannot read a Dolt-backed tracker. Its readers are SQLite-file and
JSONL-file only; the backend switch has no Dolt arm. Where it detects a Dolt
workspace it shells out to bd export -o .beads/issues.jsonl and reads the
file. Against a Dolt server it therefore renders a snapshot of whenever the last
export ran, and this tool's question is what is happening now.
Its licence is not usable. MIT plus a rider, declared to control over any conflicting MIT term, naming OpenAI and Anthropic as Restricted Parties along with anyone "acting on their behalf, for their benefit, or under their direction". It grants such parties no rights at all, defines "Use" to include analysing and benchmarking, must be carried forward unmodified into any derivative, and terminates automatically on breach.
It does not take contributions. Its README states outright that outside PRs are not merged, so upstreaming is not available either.
Two things it teaches, worth having without the code:
- Its tree duplicates a node with multiple parents, materialising a full copy
of the subtree per parent, and its id→node map keeps whichever copy was built
last — so navigating by id resolves to only one of the visible instances. An
earlier draft read that as the case for dedup in the model;
bdidraws a copy per path too, and what it learned instead is that navigation must be keyed on the way down to a copy, never on the id (see Tree construction). - Root-scoping has a direction, and getting it wrong is silent. It has two
root-scoped subgraph extractors that disagree: one walks forward from the root
along dependencies, the other backward from blocker to dependent. Only the
backward one yields "the root and everything beneath it". Same distinction as
bd dep tree --direction=up, and easy to get backwards while producing a plausible-looking tree.
Nothing here is required. This is what a setup gains by adopting two conventions, and both are one line each.
- An agent sets its pane's
display_agentto the bead id it is working on.bdithen resolves that pane to that bead. - An agent writes the reverse key when it claims a bead:
bd update <id> --set-metadata agent_pane=$HERDR_PANE_ID
bdithen detects drift exactly rather than by inference.
Beyond that, a workflow that encodes state in bead metadata — a review link, a
waiting marker, a topic name — surfaces it by naming those keys in bdi's
[[badges]] and [roots] config. bdi gains no knowledge of the workflow; it
draws what it is told to draw.
- The credential boundary is the one thing that could make the multi-project view not work as designed. It is the first thing to prove.
display_agentis free text. Any string an agent stamps lands there. Parsing it as a bead id is a heuristic; the reverse key is the reliable path, which is why both directions exist.- bd's CLI is the interface.
--jsonshapes can change. Pin the bd version the parser is written against and fail loudly on an unexpected shape, rather than rendering a silently wrong tree. - The
stale-claimthreshold is a guess. A long-running bead trips it. A warning, never a verdict, and configurable. unattributednoise. Every interactive session shows up here. If it is louder than it is useful, it becomes opt-in.