bob viz
bob viz
Section titled “bob viz”
bob warp vizshows one warp-drive session. bob viz shows BoB: every project on every fleet machine. It shows what BoB is doing now, what it did, what it cost, and what it needs from Paul. Capability #2026.This page was written with the fleet feed (#2069). The capability’s last requirement (#2074) added its design section.
What it is
Section titled “What it is”bob viz is one browser page, meant to stay open, that answers “what is BoB up
to?” across the fleet. The dashboard server serves it at /bob (#2070).
Nothing new listens on a port: it is the same server, on the same port, as a
project’s /warp monitor. Like that monitor, it is reachable on the tailnet
and nowhere else (exposure posture).
It does not replace bob warp viz. Each project’s single-session graph is one
card, and every tile links to it. The page stays small multiples: a region that
shows a project shows every project the same way, so they can be compared at a
glance.
Open it
Section titled “Open it”bob viz # prints the /bob URL and opens itbob viz --tailnet # served on this machine's Tailscale address; open the URL from a laptopOn a tailnet bind it prints the MagicDNS URL first, then labelled MagicDNS: and
tailnet IP: lines. On a localhost bind of a machine that has a tailnet
address, it says local only — not reachable from other machines and names
bob viz --tailnet. It never prints a URL that won’t open (#2918).
On a machine that declares a serve block in machines.json (#2407), the
dashboard binds loopback and bob serve puts it behind the machine’s one
MagicDNS address: bob viz prints and opens http(s)://<machine>.<tailnet>.ts.net/bob,
with the local: URL on the next line. While tailscaled does not hold the
route yet, the first line stays the local URL and a serve: line gives the
MagicDNS address marked not live yet — run: bob serve --apply.
bob dash serve --list marks such a server the same way. Setting it up:
Warp Drive → One address for the whole machine.
Behind bob serve the server answers under /<project> (/<project>/warp,
/<project>/fleet.json, …). It strips that mount, and every absolute path its
pages use (fetches, module imports, links, redirects) carries it, so a page
opened at the MagicDNS address never reaches outside its own project’s route
(scripts/dashboard/mount.js). The /bob route is the fleet project’s
dashboard, mounted the same way.
Every route has a server behind it after a reboot. The fleet project is the
BoB source checkout’s always-on service (#3001). Every other routed project
runs from a unit of its own, rendered from the same templates, and bob serve --apply installs it. There is one server per project, each on its own ledger
port. When deployed code changes, each server hands over to its manager, so
none of them keeps serving code from before the deploy (#1603). See
Project units.
bob viz starts or reuses a dashboard server exactly as bob warp viz does:
the project’s ledger port, the deploy-fresh check (server-fresh.sh) before
reuse, and the server’s live self-update. It then opens /bob instead of
/warp. Run inside a project, it uses that project’s server. Run anywhere
else, it uses the BoB source checkout’s ($BOB_SOURCE, default
~/projects/bigbrain).
To keep that server running without a terminal, install it as a service:
bob viz --install-service sets up a launchd agent (macOS) or a systemd user
unit (Linux) that starts it at login and restarts it when it exits (#3001).
With the service installed, bob viz always goes to the service’s server,
wherever you run it. File locations, the reuse rule and the bob doctor line:
bob viz Dashboard Service.
Active now
Section titled “Active now”The page opens on what is moving across the fleet (#2928). The Active now
strip sits above the machine sections. It holds one compact card for every
project that is running or needs attention on any machine, then the most
recently active projects fleet-wide, so “what is BoB doing right now?” is the
first thing on the page instead of a scan through every machine’s cap.
- What it lists. The feed decides, as
active_now[]in bob-fleet-feed/1: every running project, then every attention project, then every project an open todo waits on (#3038; one card per repo, never from a stale machine), then the rest bylast_active_at, newest first, up toDASH_ACTIVE_NOW_MAX(default 6). The cap bounds only the recent fill-ins, so a running, attention or todo project is never cut. Ties break by machine, then name, so the order is the same on every host. - The card. Each card is the grid’s compact card (#2925) with one more
row,
on, which gives its state in a word and then its machine:running · The Farm,attention · The Farm,todo · The Farm,recent · 16" MacBook Pro. The state comes first, so a narrow card cuts the machine, never the word. details expands the card in place, as on a tile. The strip card has its own key (@<machine>/<name>), so it opens on its own and never shares an element with its tile, and it reads the same/bob/project/<machine>/<name>.jsondocument. - Honest for other machines. A stale snapshot’s project is never
running. It appears only asattention (stale), and only when a fact of its own (a run, a blocked preflight, a dead run, a failingbob ready) puts it there. A stale machine therefore does not flood the strip with every project it has, and its idle projects never fill in. - No double counting. A project in the strip keeps its tile in its
machine section. The strip’s head counts its own cards,
1 running, 2 need attention, 1 needs you, 3 recent(N need youis the cards an open todo waits on, #3038), and never sums the notices waiting on them. Each card’sN need youis that project’s alone. No machine head changes because the strip is there. - Page controls. The strip is a collapsible region like a machine
(#2922), open by default. Collapsed, it still says
N running, M need attention, coloured when something needs attention. While the pointer is in the strip or one of its cards is expanded, a push keeps the cards where they are and the head offersorder changed, re-sort, as a machine section does (#2923). The name filter (#2927) narrows it too.


At 390 wide the cards stack in one column (Notices still comes first on a phone):

Every session, not only warp-drive
Section titled “Every session, not only warp-drive”A warp-drive run is not the only thing BoB runs in a checkout (#2978). A
decomposition (bob decompose) or an hour of unstructured work (bob do)
runs as a machine on the shared engine. A groom (bob groom), a flight-plan
build (bob plan build) or a ritual’s evidence packet (bob ritual prepare)
runs as a script. Each of them writes its state in one contract,
bob-session-state/1 (schemas/bob-session-state.schema.json), and bob viz
shows them the way it shows a coding run.
| Field | Meaning |
|---|---|
kind |
warp-drive, decompose, do, groom, plan or ritual. A document with no kind is a warp-drive run: its status document predates the contract and validates against it unchanged. |
active, phase |
The writer’s claim that the session runs, and where it is: a machine’s phase, or a run’s running / finished. |
target |
What it works on: {issue, area, scope}, each null when not recorded. |
started_at, updated_at, finished_at |
When it started, last moved, and ended (null while it runs). |
outcome |
How it ended: {status: ok | failed | blocked | timeout | stopped, summary, exit}. summary is the words a tile shows, such as applied 3. |
tokens, host, pid |
What it spent (null = not measured), where it runs, and the process the liveness probe checks. |
- Where each kind writes. A machine writes into its own state file
(
.claude/.decompose-state.json,.claude/.do-state.json), andmachine-engine.jsstamps the fields on every write. A PM run writes.claude/.session-runs/<kind>.jsonwhen it starts and when it exits. That directory is declared inscripts/lib/derived-paths.json, so it is ignored and never counts as in-flight work. A record that cannot be written is a warning, never a failed run. - One reader.
scripts/lib/session-state.jssessions(root)reads every session file of a checkout through the contract. It returnsactive,staleandlast, the most recently finished run. The engine’s liveness rule applies (pid, 24 h, recent-write grace): a session whose process is gone reads stale, never live (#2950).project-live.jssessionsCondition()calls the reader for the feed and foraudit.js snapshotalike. A groom on farm-01 therefore reaches this page within one refresh interval of the snapshot that carries it. - The session row. A tile gains a
runsrow whenever something other than the warp run is live, such asdecompose #2733 · deduping · 4m, orgroom area:fleet · running (dead run)for one whose process is gone. With nothing running, the row shows the last finished run instead:last: groom area:fleet 2h ago · applied 3. A live session makes the projectrunningand puts it in Active now. A dead one is adead-runattention fact. The feed computes the elapsed time and the last-run age, never the page, and a figure it did not read renders as not measured. - On the dashboard server.
/sessionsserves every session of the project root and each worktree (bob-sessions-view/1), and?root=<worktree>narrows it to one checkout./sessions/eventsstreamsevent: sessionswhenever any declared session file of that root changes. It watches.claude/and.claude/.session-runs/, with a 1 s mtime poll behind the watch./warpis unchanged. - warp viz for a machine. The warp viz header has a kind switch.
/warp?kind=decompose(or?kind=do) draws that machine’s phase graph, taken from its own definition, and its run, from the same page controls.bob decompose statusandbob do statusend with aviz:line giving that URL, or the command that starts the dashboard.bob warp statusprints the same line for/warp(#2639). - Every machine has a screen (#3815). A machine on the shared engine gets
its session screen from one entry in
scripts/lib/session-machines.json: its name, the module that exports itsMACHINE, and its state file. With that entry/warp?kind=<name>shows the machine’s declared phases in order, the current one marked, and its transition history, the kind switch offers it, and/sessionslists its run. The machine needs no page code. A?kind=that names no kind answers HTTP 404 and names the kinds that exist.
The grid
Section titled “The grid”The grid has two layouts. It opens by project (#2929): one tile per repo,
whichever machines have it checked out (By project). By
machine is the alternate: one section per machine and one tile per checkout,
described here. The by project / by machine toggle in the page header
switches between them. The rest of this section describes the by-machine grid,
and By project says what changes.
By machine, the grid has one section per machine, in machines.json order, and inside it one tile per
project, in the order of the grid’s sort by select (#2477). The machine
header shows the source (live, a snapshot and its age, a stale snapshot, or
the reason nothing was published) and the machine’s counts in words. Only
the machine serving the page opens by default, because its rows are the live
ones. Every other machine starts collapsed, and its head still says
N projects, X running, Y need attention, Z need you (Z counts the projects
an open todo waits on, #3038, and shows only when there is one). Each tile is a compact card
(#2925), laid out around the questions asked while scanning: is it moving,
is it OK, does it need me, what does it cost. It has a head and four rows:
| Row | Shows | From |
|---|---|---|
| head | The project name, its state mark, parked or archived, the needs-you mark (○ todo, ○ 2 todos · 1 p1) when an open todo waits on it, and N need you when notices wait on it. A tap anywhere on the head selects the project across the page (Selection); the selected tile is outlined and says selected |
The feed’s state, reasons, portfolio and needs_you; the count from /notices.json |
| now | coding · #2918 · chunk 2/4 · 12m for a live run (the chunk counted from 1). no run for session: none, the phase and (dead run) for session: stale |
The feed’s phase, issue, chunk and elapsed_s |
| todo | Only while an open todo waits (#3038): the oldest one’s priority, its words and how long it has waited (p1 · TODO: rotate the token · waiting 2d), then its exact command in a .cmd block — the Notices row’s command. When the todo has a handoff record (#3058), its one-line text and command instead, so the card, the banner and the phone push say the same thing |
The feed’s needs_you.oldest |
| last | 3h · feat(#2899): bob tour --fleet…: the last commit’s age and subject, cut to the tile’s width, whole in the tooltip with the short SHA. With no readable commit, when the project last moved (#2923) |
last_commit, else active_age_s |
| health | ready, or the failing verdict and its first failing check (blocked: labels), then the verdict’s age (2h ago). A verdict older than a day (READY_STALE_SECONDS, 86400) reads stale verdict, 2d old instead (#3099). bob ready and every warp preflight refresh the verdict. stale 26h follows only when the machine’s snapshot is stale |
project_ready (with age_s and stale), and the machine’s stale and age_s |
| spend 30d | usd_per_day as a sparkline and its 30-day total, ≤ when it is an upper bound |
/series.json (#2072) |
Below the rows, details expands the card in place
(Expanded card). The link beside it opens the project’s
bob warp viz card in a new tab. A project with no known link says so, and
its tooltip gives the reason.
Tokens this run, tokens 30d and the snapshot age of a fresh snapshot moved to
the expanded card. The compact card has no CI mark. The feed is pushed to
every open page and never calls gh, so the last CI run is read only when a
card is expanded (health.ci.last_run in
bob-project-detail/1).
A stale snapshot’s tiles are dimmed, and every null reads not measured. The
N need you count shows nothing when no notice waits, or while
/notices.json is unread. The spend row is read on the machine that serves
the page, so a tile on another machine shows not measured there, with the
reason as its tooltip.
The state mark (#2477). Each tile carries the feed’s state as
data-state. A running tile has a dot before its name in --state-active,
and an attention tile has one in --state-bad. The dot’s tooltip names the
feed’s reasons[], for example needs attention: dead run, stale snapshot.
An idle tile has no mark and no fill. The dot is never the only signal: the
tile’s own rows already give the phase, the verdict and a stale snapshot’s age in
words.
The needs-you mark (#3038). A project an open todo waits on — “run
deploy.sh”, “add the secret”, the thing only Paul can clear — carries a
hollow ring and a word after its name: ○ todo, ○ 2 todos, ○ 2 todos · 1 p1.
The fact is the feed’s: needs_you {count, p1, oldest} per project, derived
from the same todo notices the Notices column lists, and its state is
needs-you when nothing is running or wrong there. A todo never outranks a
blocked or live run: those keep their dot and still show the ring. The ring is
plain ink, never a state colour — a todo waits, it is not wrong — so it reads
the same in both themes and never looks like attention. The mark clears on
the feed refresh after the todo gets completed or is closed, with no viewer
action: the server restamps the feed as soon as a notices build sees the todo
set change (at most one notices refresh, DASH_NOTICES_REFRESH_S, 60 s by
default, while a viewer is connected). While the todo source is unread the
fact is null, not measured, and no mark shows.
The tile sort (#2477). One sort by select above the grid orders the
tiles within each machine section:
| Key | Order |
|---|---|
recent (default) |
Running tiles first, then the feed’s last_active_at, newest first (#2923). A project whose recency is not measured sorts last, and its active row reads not measured |
name |
By project name |
state |
attention, then running, then needs-you, then idle (#3038): a todo never outranks a blocked run, and always outranks an idle project |
tokens (this run) |
The run’s tokens, largest first |
spend 30d |
usd_per_day from /series.json, largest first. Until the series are read, the grid sorts by name and says so beside the select |
A tie keeps name order, and a value that was not measured sorts last, never as zero. So the snapshot test and a browser render the same bytes.
Stable under pushes (#2923). A push can reorder tiles under recent or
state, but it never moves a tile out from under the viewer. While the
pointer is over a machine section (its tiles or its head), or one of its
cards is expanded
(.tile.expanded, set while its expanded card is open), a
repaint keeps that machine’s tiles in the order on screen: a tile the push
added joins at the end, and one it removed is dropped. When the live order
differs, the section head shows order changed, re-sort. Clicking it,
choosing a cap, or moving the pointer out of the section applies the new
order. The head is inside the hold, so the button stays put while the pointer
reaches it (#2956). The renderer takes
the held order as hold (holdTiles in bob-tiles.js), so the snapshot
test covers it.
The cap (#2923). The grid opens on what is active. Each machine section
shows 6 tiles by default. A segmented 6 / 12 / all control in the section
head (shown once a choice would change anything) switches it. The choice
persists per viewer under bob-viz-view/1: a click sets that machine
(cap["machine:<id>"]) and becomes the default for every machine the viewer
has not chosen yet (cap.all). While tiles are hidden, the head leads with
6 of 23 projects.
The cap never hides a problem. A tile whose state is running or
attention, or that an open todo waits on (#3038), is always shown, even past the cap. The head then names how many
are, for example 8 of 23 projects (2 over cap: running/attention/todo). Under
recent a running tile already leads, so it is usually an attention tile
that sits past the cap.
Parked and archived projects step aside. A project whose feed row carries
portfolio: park or archive (the manifest’s _meta.portfolio, written by
bob ritual decide) sorts after every active project under every sort key.
It is shown only under all, with a parked or archived word after its
name. A parked project with a live run or a problem is still shown, like any
other pinned tile.
The tiles are rendered by scripts/dashboard/bob-tiles.js. That is a pure
function of the feed: it reads no clock and computes no figure. The page
imports it from /bob/tiles.js, and tests/test-bob-viz-page.js snapshots
the same module’s output. The page takes event: fleet pushes on
/warp/events, and re-reads /fleet.json once per refresh_s, because a push
leaves ages out. Light and dark themes come from
scripts/dashboard/templates/design-tokens.js.
By project
Section titled “By project”The same repo is often checked out on more than one machine: bigbrain on farm-01 and on the laptops, for example. By machine, it reads as several unrelated tiles, with its notices and spend split between them and no clear answer to which machine is doing the work. By project (#2929), the default, merges them into one tile per repo.
- Repo identity comes from the feed. Each checkout’s
repoisowner/namefrom its origin remote, normalised across the SSH and HTTPS forms, so a checkout under another directory name on another machine still joins its repo. The feed groups them asprojects_by_repoand carries each repo’s combined figures inrepos(Fleet feed). A checkout with no origin remote (repo: null) is never merged: it keeps its own tile, in a section for the machine that has it, below the repos. - The tile. The head is the repo’s name (
owner/namein its tooltip), its combined state mark,N machines, andN need you. The combined state isrunningwhen any checkout runs, else the worst of the rest, so a laptop’s stale copy does not paint a live repoattention. Then there is one row per machine: the run (live run · coding · #2929 · chunk 1/2 · 12mon the machine holding it,no runelsewhere), last activity, the checked-out branch, andliveor the snapshot’s age. - Divergence is flagged. When one checkout’s default branch is behind
another’s, its row says
behind The Farm by 3. The feed works this out from the SHAs the snapshots carry (divergence), with no fetch. When they do not carry enough, because the branches diverged or are more than 50 commits apart, the row saysnot measured. - Figures are shown once, or per machine, never summed by the page.
Forge facts about the repo are counted once. The
N need youcount is every notice across its checkouts, each counted once. Local facts (the run, tokens,bob ready) stay on each machine’s row.spend 30dis shown per machine (spend 30d · The Farm), because/series.jsonsupplies no per-repo total, and the page never adds them up. - The other controls work the same.
sort byorders repo tiles.recentuses the newest activity across the repo’s checkouts, andtokensuses the live run’s.spend 30dhas no per-repo value, so it sorts by name and says so. The6 / 12 / allcap counts repos over the whole fleet (6 of 14 repos), not projects per machine, and still never hides a running or attention repo. The name filter matches a repo by its name or any checkout’s. A push holds the order on screen as a machine section does. - The Active now strip lists repos. By project, each repo appears once in
the strip, where its first checkout stands in
active_now[], so the strip and the grid never repeat one repo per machine. - The expanded card lists every checkout.
detailsopens the card of the primary checkout: the one holding the live run, else the one that moved last. Above its sections is a Checkouts list with every checkout’s machine, directory name, run, branch, snapshot age and divergence. A#card=<machine>/<name>deep link or a notice’s project link reaches the repo tile that holds that checkout. - The choice persists. The layout is stored per viewer in
bob-viz-view/1and mirrored in the URL fragment as#layout=machine(the default is left out), so a pasted link opens the same grid. A feed older than #2929 carries noprojects_by_repo, and the grid then renders by machine whichever layout is chosen.
Expanded card
Section titled “Expanded card”Each card’s details control opens it in place, into a detailed view drawn
from bob-project-detail/1 (#2925). The
compact rows stay at the top of the card. Below them, the expanded view opens
each row up and adds the facts and links that would otherwise cost a click
into GitHub, a terminal or warp viz:
| Section | Shows | From the detail document |
|---|---|---|
| Now | The phase, the issue number and title (linked), chunk N/M, elapsed, tokens this run, the branch, the host session (tmux or iTerm, with its window), the flight plan (linked, done/total) and the bob warp viz card link. A project with no run says no run and still links its card |
session |
| Recent | The last 5 commits (each linked, with its age and subject), then the open PRs with their CI conclusion and a draft word. A project on another machine shows only its last commit and says why |
activity |
| Needs you | This project’s notices with their commands, rendered by the Notices column’s own renderNoticeList (bob-notices.js), so the card and the column cannot word a notice differently |
/notices.json (projectNotices) |
| Queue | The approved count (linked to the approved issues), the next item (linked, from the flight plan or the approved queue), open bugs, open p1s | queue |
| Health | The bob ready verdict and each failing check with its fix command, a blocked preflight with its todo and fixes, the CI locus (label, verdict, drift and its fix), the last CI run (linked), harness defects (linked) and open dependency advisories by severity |
health |
| Cost | Spend 30d and tokens 30d sparklines, $ / merged req, $ / run and fix : feat |
cost (this server’s bob-series/1) |
| Reports | The latest session report and tick digest (each linked, with its time and a tick’s verdict) and the latest stored briefing | reports |
Below the sections sits a links row: repo · approved issues · PRs · Actions ·
warp viz · dev · prod · docs. A link the document has no URL for is left
out, never shown dead. A provenance footer names the machine, live or the
snapshot’s age (marked stale), and when the detail was read, as an absolute
time, since the renderer reads no clock.
Every gap says why. A section the document marks not-measured or
unavailable renders that word with its reason. A field it could not read
renders not measured, with the document’s not_measured reason as its
tooltip. A degraded forge puts one line at the top, naming the gh reason,
its fix, and whether an earlier copy is shown. An unread queue is never a
zero, and nothing needs you is printed only when the notices were read.
Opening a card. details is a button with aria-expanded, so it works
from the keyboard. Keyboard focus stays on it across every repaint: the
document landing, a push or a re-read (#2963). On the first open the page fetches
/bob/project/<machine>/<name>.json, and the card says loading until the
document lands. A failed read says so in the card. While the card stays
open, the page re-reads the document every refresh_s (the server rebuilds
it at most once per 120 s by default), and a failed re-read keeps the last
good document. Several cards may be open at once. An open card spans two grid
columns, or the full row below 56rem wide. A push over /warp/events
re-renders an open card from the new feed row and its last document, so the
card never collapses. Its machine’s tiles also hold their order while it is
open (Stable under pushes). The cap never hides an open card,
though the head does not count it as running or attention.
Deep link. #card=<machine>/<name> opens the page with that card
expanded and scrolls it into view. It scrolls again once the card’s document
lands, because what loaded above it in the meantime (Notices, on a phone)
has moved it down. The fragment can be repeated
(&card=…) and mixed with the sort keys. #card=/<name>, with no machine,
opens the project’s first tile on any machine, since a todo names a repo and
not a machine. A notice’s machine / project link carries that fragment as
its href. Clicking it opens the card and reaches the tile, and a copied link
opens the card directly. A Telegram notification can point at the same URL:
http://<host>:<port>/bob#card=<machine>/<name>. parseCardHash and
cardHash in bob-controls.js read and write the fragment.
The expanded view is rendered by scripts/dashboard/bob-card.js
(renderCard), a pure function of the feed row, the detail document and the
project’s notices. tests/test-bob-card.js snapshots it
(tests/fixtures/bob-viz/card.html) from a detail document captured on
farm-01, including a remote stale snapshot, a degraded forge, a blocked run
and no run. The page serves it at /bob/card.js. tests/test-bob-viz-page.js
snapshots the grid with a card open (tiles-expanded.html).


Dispatch from the card (#3003). Above the card is a chat input. Type an
instruction such as brief, groom area viz-dispatch, or a warp run on
one issue (the operation name followed by the issue number), read
what it resolves to, press Run to start it here in tmux, and cancel it from
its session row. Every part of the reply comes from the server’s operations
table, and the page decides none of it. The replies, the keys and the trust
boundary are in the reference.
Which harness runs it (#3005). BoB is meant to run in more than one agent harness (#498): Claude Code today, and later Codex CLI, Gemini CLI or Cursor. Dispatch is where that choice becomes visible. The chat input has a harness selector, and the preview names the harness the operation will run in. Only Claude Code can be picked. The others are listed but disabled, and each one names the issues it waits on: #499, which splits BoB into a harness-neutral core with one adapter per harness, and #504, which checks that each harness keeps BoB’s guardrails before it may run unattended.
The seam is one file, scripts/dashboard/harnesses.json. The operations
table never names a harness, and the launcher builds no command of its own.
Both take the harness’s command from its registry entry. So the step to a
second harness is one entry turned on plus its adapter, with no change to the
page, the server or launch.sh. When #499’s adapter registry exists, the
selector becomes a view over it. The server refuses a request that names a
disabled harness, so the page cannot start one by any route. A shell
operation such as bob brief runs no harness, and its preview says none.
The fields are in the reference.
Needs you banner
Section titled “Needs you banner”While anything waits on Paul, a banner spans the top of /bob, above every
pane (#3058). It covers any open todo on any fleet project and any run
blocked at preflight. The banner shows:
- how many things wait, e.g.
3 things need you across the fleet, in the state colour - the most urgent item’s one-line text in large type, with its machine and project. A blocked run leads, then the item with the highest priority, then the longest wait.
- why it matters
- its copy-paste command in a
.cmdblock - a link that opens the todo
- how many more wait, and that they are listed under Notices
The words are the handoff record’s:
the same line Paul’s phone push and the run’s terminal handoff show. A todo
with no handoff record falls back to its title and its completed command.
A flight plan drained on a todo is labelled flight plan waiting on you, so a
stalled plan never sits silently.
The banner reads the same /notices.json as the Notices column. It clears
with the notice, within one refresh of the todo getting completed or being
closed. It builds on the per-card Needs you mark (#3038) and does not
replace it. It is loud by size and placement, not by fill or border: its
count is .bad, the page’s one state colour (see Design).
scripts/dashboard/bob-handoff.js renders it for both pages, and the
bob warp viz page draws the same banner for one run (see warp viz).
GitHub quota
Section titled “GitHub quota”Under the header, /bob prints one line about the GitHub quota (#3190):
GitHub quota: graphql 4,120 of 5,000 left · core 4,990 of 5,000 left · resets 10:47 · 1,480 points spent this hour · 412 calls this hour (bigbrain 300, example-web/groom 96, flows 16) · rate-limited this week: 0Every session on every machine that holds one GitHub token spends one quota.
The gh shim keeps it from running out (see
The quota budget). The
line shows that it is working.
- What is left comes from the freshest reading in the fleet. Machines that share a token read one server-side number, so the newest reading stands for all of them. The line’s tooltip names the machine that read it.
- Who spent the hour is summed across machines: calls in the last hour, and the three callers that made the most.
- Rate-limited this week is summed across machines too. The target is 0. Any other number is drawn in the state colour and named a forge defect.
The line is quiet. It uses the state colour in two cases only: a quota under
its floor, where unattended calls are waiting for the reset, and a rate-limited
answer this week. It reads the fleet feed’s quota key, so it costs no forge
call. A feed that carries no quota draws nothing. scripts/dashboard/bob-quota.js
renders it.
Notices
Section titled “Notices”Beside the grid, the Notices column lists only what needs Paul, oldest wait first. It is built from the bob-notices/1 document (#2071):
| Kind | What needs Paul | Action |
|---|---|---|
| todo | An open todo without completed |
Its link, plus the command that adds completed |
| blocked | A warp run held on a failed preflight, on any machine | The failed checks’ fixes, plus the todo |
| decision | An issue whose latest comment is a ## Decision Needed |
The comment |
| release | A stable tag newer than this machine’s deployed version | bob update |
Each entry shows its kind, machine and project, how long it has waited, and its command (selected in one click) or link. A notice is gone soon after its source clears.
Filter (#2477). Beside the sort, one select per key narrows the column:
kind, machine and priority. The project is not one of them: it is the
page-wide selection (#3039), and the
column’s own filters compose with it. The choices are the values the notices
carry, in codepoint order, and priority is the source issue’s p1-critical..p4-low.
Filters combine. Each active filter shows a chip, such as kind: todo ×,
that clears it. When a filter or the selection hides any notice, the head reads
2 of 5 need you. Its blocked count still covers every notice, so a filter
never hides a blocked run. “nothing needs you” appears only when nothing is
waiting at all, whatever the filter. A filter lasts for the page view only:
it is neither stored nor put in the URL.
Linked selection (#2477). Colour marks state, never identity, so a
project is followed by selection instead: see
Selection. A notice’s
machine / project is a link that selects the project and reaches its tile.
It opens the machine’s section if it is collapsed, scrolls the tile into view
and outlines it briefly in --border, with no fill. A todo or a decision
lives on the forge, not on a machine, so it reaches the project’s tile on
whichever machine lists it.
Below the entries, one did you know (#2077) names a tool provisioned in the
server’s project that has never been invoked there, taken from bob tour. It
shows the command to try it and bob tour --park <item>, which dismisses it
for good. The item changes once a day, is never one Paul has parked, and is not
counted as something that needs him. Discovery happens on the page Paul already
reads. The server rebuilds /notices.json once per refresh_s
(DASH_NOTICES_REFRESH_S, default 60 s), and the page reads it every 10 s.
Under the entries, each source that was not fully read says so:
- An unreadable forge source shows its gh reason and the fix, such as
gh auth loginor restarting a server that can no longer run gh. - Bridge questions show
not measured, because the worker has no read endpoint yet. - Machines that published no snapshot are named.
“nothing needs you” appears only when every source was read. If a source
failed and nothing was found, the column says so instead. It is never blank.
The column is rendered by scripts/dashboard/bob-notices.js, the same kind of
pure function as the tiles.
Activity
Section titled “Activity”Under the grid, the Activity pane lists every project in the fleet by its last commit, newest first (#2154). Each row shows:
| Column | What it is |
|---|---|
| Name | The project. It selects the project across the page and reaches its tile, like a Reports row |
| Sparkline | 30 bars, one per day, oldest on the left. A day with commits is a bar scaled from 0 to the busiest day; a day with none is a one-unit tick on the baseline, so a quiet project still reads as a timeline. The tooltip names the range, the total and the busiest day |
| Branch | The checked-out branch, and the word dirty when the tree has uncommitted changes |
| Age | How long before the source read the project its last commit was made |
| Subject | The last commit’s subject, cut with an ellipsis and whole in its tooltip |
The page does not scan git. Every figure comes from the fleet feed it already
reads for the tiles: last_commit and branch, and activity: {dirty, commits_by_day}, which each machine’s
fleet snapshot records with one
git log per checkout. A project checked out on several machines is one row:
the checkout with the newest last commit, which links to that machine.
Ages are measured from the time the source read the project: the snapshot’s
time for another machine, the feed’s for the serving one. A stale snapshot
therefore reads as stale. Its row is dimmed and says stale snapshot, and its
age is as of that snapshot, never extrapolated from the browser clock.
A project with no activity data (a missing checkout, a directory outside git,
or a snapshot published before #2154) is still listed, under a no activity data divider at the end, whatever the sort. The pane sorts by last commit
(default) or name, folds after 6 rows and follows the filter box and the
page-wide selection. It is rendered by scripts/dashboard/bob-activity.js,
served at /bob/activity.js, and Control’s Fleet page carries it too.
Cost and churn
Section titled “Cost and churn”Under the grid, the churn strip shows where the money goes and how the work churns, one row per project registered on the serving machine, over 30 days. It is built from the bob-series/1 document (#2072):
| Column | What it shows |
|---|---|
| $ / merged req | USD per issue a default-branch commit referenced, priced on the sessions that name it. The figure token-report.js --issue N reports for the same window |
| $ / run | USD per warp-drive session |
| fix : feat | fix commits per feat commit, per 7-day bucket and for the window |
| harness defects | Recurrences recorded on the project’s warp-drive state file now: a count, not a trend |
| flaky reruns | Recurrences of checks quarantine-report.js filed, by the day last seen |
| CI duration | Seconds per completed gh run list run, and the median |
| releases | vX.Y.Z tags cut per day, and the count |
Every dollar comes from token-report.js’s rate table (#1805), through the
same functions the CLI calls, never re-priced in the page. A figure priced on
an upper bound, because a session record carries no model, reads ≤$.
Sorting (#3097). Every column sorts. Each header, project included, is
a button: clicking it sorts the table by that column, and clicking the active
column’s header again reverses it. The active header carries aria-sort and an
arrow, and no other header does. The sort by select above the table offers
the same keys plus spend 30d, which has no column, and it stays in step with
the headers. The table opens sorted by $ / merged req, most expensive
first, so it leads with where the money is least productive. A numeric column
sorts largest first, and a value that was not measured sorts last in either
direction. A reversed sort is carried as the column’s key with its direction,
#sort.churn=usd-per-merged-req-asc or #sort.churn=project-desc, in the
same stored view and URL fragment as every other sort (#2922).
Each cell is a sparkline and its value. A series whose source could not be read
(no token log, no GitHub origin, a gh failure) reads not measured, with the
reason as its tooltip. It is never an empty chart that looks like a zero. The
server rebuilds /series.json at most once per refresh_s
(DASH_SERIES_REFRESH_S, default 300 s), and the page re-reads it on that
interval. The strip and the tile rows are rendered by
scripts/dashboard/bob-series.js, the same kind of pure function as the tiles.
Reports
Section titled “Reports”Under the churn strip, the Reports pane lists the fleet’s reports of the last 14 days, newest first. It is built from the bob-reports/1 document (#2073):
| Kind | What it is | Link |
|---|---|---|
| tick | A bob tick digest: its verdict, and its markdown (taken, merged, blocked, bugs filed, budget, steps) rendered in place |
The plan issue the tick ran, which carries the digest comment |
| session | A session-report issue a warp-drive run filed |
The issue |
| status | A status-report issue /status --file filed |
The issue |
Each entry shows its kind, machine and project, and its date. The newest digest is open; the rest fold. Reports are a view here, not a scroll through GitHub.
A tick writes its digest only on the machine that ran it. The serving
machine’s digests come from $BOB_HOME/fleet/ticks/. Every other machine’s
newest digest arrives as its snapshot’s last_tick, so a tick on farm-01 shows
up on a laptop within one snapshot interval. A machine that published no
snapshot, or whose snapshot predates last_tick, is named under the entries as
not measured, never read as a machine that ran no tick.
When gh cannot be read, each issue source shows its gh reason and the fix, and
the digests still list. “no reports in the last 14 days” appears only when
every source was read. The server rebuilds /reports.json at most once per
refresh_s (DASH_REPORTS_REFRESH_S, default 120 s), and the page re-reads it
on that interval. The pane is rendered by scripts/dashboard/bob-reports.js,
the same kind of pure function as the tiles. It renders digest markdown from
escaped text, so an issue title inside a digest cannot inject markup.
Briefings
Section titled “Briefings”Under Reports, the Briefings pane lists BoB’s written answers to “what
happened and what needs me”, newest first (#2926). It is built from
/bob/briefings.json: the bob-briefings/1
index of stored portfolio briefings, extended with the open area briefs and
the open project briefs (#3040).
| Kind | What it is | Needs you | Links |
|---|---|---|---|
| portfolio | A stored briefing/1 document from bob brief --write / --send. The newest is marked latest and is always shown, even past the fold (#2967) |
Its counts.needs_you |
Its view on /briefings (/briefings?b=<file>) |
| area | An open brief-labelled issue that bob brief --area keeps (<!-- brief:area area:<slug> -->), with its brief: status word |
The todos count in the brief’s evidence table | The issue, and the published page when the evidence block links one |
| project | An open brief-labelled issue that bob brief --project keeps in the project’s own repo (<!-- brief:project <name> -->), shown with its repo#number and brief: status word |
The todos count in the brief’s evidence table | The issue, and the published page when the evidence block links one |
An area brief’s date, published link and counts (open, approved, todos, PRs)
come from the evidence block bob brief --area writes into the issue. They are
read, never recomputed, so the pane says what the brief says. A brief
regenerated more than 7 days ago says its age in words, such as
regenerated 9d ago.
The area briefs are one gh issue list --label brief read in the BoB source
checkout. The project briefs live in each project’s own repo, so they are one
gh search issues --owner <owner> --label brief --state open read over the
owner of the BoB checkout’s origin, deduplicated against the first read (the
BoB repo’s own project brief arrives from both) — sources["project-briefs"]
in the document. When gh cannot be read, the pane names the gh reason and the
fix that clears it for that source, and the portfolio briefings still list.
The pane sorts by newest, kind, needs you or project (the area label or the
project name; a portfolio briefing sorts last). With a project
selected (#3039) the pane keeps only
that project’s own briefs, listed first whatever the sort: its project brief,
the area briefs filed in its repo, and its area-completion briefings. A
portfolio briefing covers every project, so it stays, and its link opens
/briefings?b=<file>&project=<name>, which shows only that project’s rows,
each section counting what it shows. With nothing selected, a filter query
that names a project exactly lists its brief first. “no briefings” appears
only when every source was read. The server rebuilds /bob/briefings.json at
most once per refresh_s (DASH_BRIEFINGS_REFRESH_S, default 300 s), and the
page re-reads it on that interval. The pane is rendered by
renderBriefingsPane in scripts/dashboard/bob-briefings.js, the same module
the /briefings page renders a briefing with. A stale brief’s row is dimmed
and still listed, like a stale snapshot’s report.
tests/test-bob-viz-page.js snapshots the pane from committed fixtures
(tests/fixtures/bob-viz/briefings-pane.json →
briefings-pane.html), including gh unreachable, an empty pane with every
source read, and an empty pane with gh unreachable:


A tile’s link comes from /bob/links.json. That is navigation only, built
from machines.json and the port ledger, with nothing probed, the same rule
as the monitor’s fleet links:
- this server’s own project links to
/warpon the same origin - another project on this machine links to its ledger port, on the host the page was opened on
- a project on another machine links to its tailnet address and ledger port
- a project with no ledger port, or on a machine with no tailnet address, gets no link, and the tile says which is missing
- a project routed by a machine’s
serveblock (#2407) links to that machine’s MagicDNS address instead,http(s)://<machine>.<tailnet>.ts.net/<project>/warp: HTTPS certificates are issued for the name, not the IP. On this machine the link is used only when tailscaled holds the route; another machine’s is taken from its declaration, and fails on click like any other link if that machine has not applied it. The monitor’s fleet row follows the same rule.
Selection: one project across the page
Section titled “Selection: one project across the page”A project is selected once, and the whole page follows it (#3039, building on
#2477). There is one selection, view.project, and the grid and every
project-scoped pane read it:
- Select. Tap anywhere on a tile’s head (its name, state mark, needs-you
mark or
N need youcount). One tap is enough at phone width, and nothing depends on hover. The tile’sdetailscontrol and itsbob warp vizlink sit in the foot and keep their jobs. A row’smachine / projectlink in Notices, Reports or Briefings selects too, then reaches the tile, so selection is the same from the grid or from a pane. By project (#2929), the selection is a project name, not a machine: every checkout of the repo selects together. - What follows it. The selected tile is outlined (2 px, in
--text-muted) and saysselectedin a word, in both themes. Notices, Cost and churn, Reports and Briefings keep only that project’s rows. The match is exact:bigselects nothing, where the name filter would matchbigbrain. A pane with nothing for the project says so in words:no notices for <project>,no reports for <project> in the last 14 days,no cost or churn for <project> on <machine>,no area or project brief for <project>. The Notices filters (kind, machine, priority) stay the column’s own and compose with it. Thefilter projectsbox showsselected: <project>as its placeholder. - Clear. One chip in the page header,
selected: <project> ×, clears the selection in every pane at once. It appears once, never per pane. - Keep and share. The selection persists per viewer in the stored view
(
bob-viz-view/1), beside the tile cap and the sort choices (#2923), and rides in the URL as?project=<name>. A link with?project=opens the page pre-selected and wins over the stored choice. A selection restored from storage is written back into the URL, so the copied address carries it. A?project=naming a project on no machine in the feed is reported beside the chip (<name> is not on any machine), never silently ignored.
Each renderer takes the selection as opts.project (the grid as
opts.selected), so the panes are tested like the query. The
--- project selection: end to end in headless Chrome --- section of
tests/test-bob-viz-page.js drives the real page, served by the dashboard
server with fixture data, at 390 px with touch taps. It checks a pre-selected
link, the chip clearing every pane, a tile-head tap selecting and persisting
across a reload, a Reports row selecting, a project on no machine, and the
outline in both themes. It snapshots the panes in
tests/fixtures/bob-viz/selection.html. It needs a headless Chrome ($CHROME,
or the Playwright chrome-headless-shell) and says SKIP without one.
Page controls
Section titled “Page controls”Every region is collapsible, every list can be sorted, and a long list folds
(#2922). The three controls are built once, in
scripts/dashboard/bob-controls.js, and every renderer uses them. A region
added later, such as the #2477 grid controls, the Briefings pane or a card,
reuses them instead of building its own.
-
Collapse. Each region is a native
<details>: Active now, each machine, Notices, Activity, Cost and churn, Reports and Briefings. Its head opens and closes it by click or from the keyboard. A collapsed head still gives its count in words, such asNotices 4 need you, 1 blocked,Reports 12 in the last 14 daysorThe Farm 3 projects, 1 running, 1 needs attention. The count is coloured only when it holds something running or something wrong, so collapsing a region never hides a problem. collapse all in the page header closes every region. Once they are all closed it becomes expand all. -
Sort. Each list has a
sort byselect, and each list declares its own keys. The grid:recent(default),name,state,tokens (this run),spend 30d(The grid). Notices:oldest wait(default),kind,project,machine. Activity:last commit(default),name(Activity). Reports:newest(default),kind,project. Briefings:newest(default),kind,needs you. Cost and churn:$ / merged req(default),project,spend 30dand every other column, each also sortable by clicking its header (Cost and churn). The sort is stable and in codepoint order. A value that was not measured sorts last in either direction and is never treated as zero. -
Fold. A list longer than 6 rows shows the first 6 of the sorted list and ends in
show 10 more (N hidden). The last click names what remains, for exampleshow 4 more (4 hidden). Once every row is shown the footer offersshow fewer. A push over/warp/eventsor a re-poll keeps the number of rows the viewer revealed. -
Pinned rows. A fold never hides what needs Paul. In Notices, a
blockedrun and ap1todo or decision are always shown, even past the fold. In Briefings, thelatestportfolio briefing is. The footer counts only the rows still hidden. -
Filter by name. The
filter projectsbox in the page header narrows the page to the projects whose name contains what is typed, case-insensitive (#2927)./focuses it from anywhere but a text field,Escclears it, and aname: <query> ×chip beside it clears it too. It applies to every machine section, Notices, Cost and churn and Reports, each by its rows’ project name, and to the area and project briefs in Briefings by the area’s or the project’s name — a query naming a project exactly also lists its brief first. A portfolio briefing covers every project, so it always stays. While the query hides rows, a head readsN of M:2 of 23 projects,1 of 4 need you,3 of 14 in the last 14 days. A machine’s running and attention counts stay over every project, so a collapsed section still counts a running or attention project that matches, and a filter never hides a problem. In Notices,nothing needs youis never shown while the query hides a notice, and the query composes with the Notices filter (#2477). A query lifts the 6 / 12 / all tile cap (#2923): every match shows, and the cap control is hidden while the query is set. The query is a renderer option (opts.query), sotests/fixtures/bob-viz/query.htmlsnapshots every region under it. -
Layout. The
by project / by machinetoggle in the page header picks the grid’s layout (#2929): one tile per repo (the default) or one per checkout under its machine (By project).
The view is the viewer’s own. Which regions are collapsed, each list’s sort,
the grid’s layout (#2929) and the grid’s tile caps (#2923) persist in localStorage under the versioned
key bob-viz-view/1.
Only a choice that differs from a region’s default is stored, so a machine
the viewer opened stays open on the next visit. Every read and write of it is
guarded, so with storage blocked the page still renders with its default
view: the serving machine and every other region open. The sort keys are also mirrored in the URL
fragment, as in /bob#sort.notices=kind&sort.churn=spend-30d, so a link
pasted into Telegram or iBoB opens the same view. When the fragment and
storage disagree, the fragment wins. The number of rows revealed is kept only
for the current page view. The name filter is mirrored in the fragment too,
as /bob#q=bob, so a link carries it, but it is never written to
localStorage: a fresh visit starts unfiltered.
At 390 wide, the iBoB width (#2293), the heads wrap and every button and select is a touch-sized target:


Filtered by bo, the box holds the query and the chip beside it clears it.
Each machine says how many of its projects match, and a machine with none
says so. At 390 wide the box takes the full row and is touch-sized:


/briefings keeps its own fold: 8 items per section, then the rest under one
native N more disclosure (FOLD_AFTER in bob-briefings.js). A briefing
is a stored document, read once from top to bottom on a phone. It has no
re-poll to survive and no per-viewer state to keep, so a single disclosure
serves it better than 6 rows plus 10 more.
Design
Section titled “Design”The page is meant to be read at a glance and studied for a long time. #2074
reviewed every region against Tufte’s principles, using the dataviz and
frontend-design skills. The rules it settled are kept as checkboxes in the
design checklist. A new pane cites
that list, and tests/test-bob-viz-page.js enforces what can be checked
mechanically.


Both images are rendered from the committed pane fixtures, not from live data:
node scripts/dashboard/bob-viz-shots.js regenerates them.
Data-ink
Section titled “Data-ink”Ink that carries no data is removed. A tile had a border and a card fill. Every churn row, notice and report had a rule under it. The machine tag was a pill, and each command sat in a box. None of those said anything the text did not already say, so all are gone:
- Whitespace separates. Tiles sit in a gutter, and list entries are spaced apart.
- One hairline rule (
--hairlinein--border) sits under each section head and each table head. It marks where a region starts, and nothing else does. - Fills are limited to the page ground, the theme button, and a hovered tile, which shows that the tile is a link.
Small multiples
Section titled “Small multiples”The grid compares projects, so every tile is the same shape. The width is
fixed by the grid, the rows are the same and in the same order, and an unread
value keeps its row as not measured instead of dropping it. Machines follow
machines.json. Projects follow the viewer’s sort key, with name as the
tiebreak. Under name a tile’s position is stable from one refresh to the
next, so a change in a value is the only thing that moves. Under recent or
state, a tile moves only when its activity or state changes, and that move
is itself the news. A stale machine keeps its tiles, dimmed, so its absence from the
live state is visible.
Sparklines
Section titled “Sparklines”The dataviz skill’s line spec (2px lines, markers at least 8px, a legend for
two or more series) is for charts. /bob draws only word-sized sparklines,
Tufte’s term for a chart about the size of a word. Those follow the skill’s
rules for a single series:
- one series, so no legend: the column head or row label names it
- a line in neutral ink (
--viz-line) with no fill, axis or grid - the last known point marked with a dot in
--viz-point, and the value beside it as the only label - scaled from 0 (or the lowest value, when negative), so a flat series is not magnified
- a gap for a missing point, and
not measuredfor an unread series
The sizes are 64×16 in the churn table and 40×12 on a tile. At that size a 2px line fills the row, so the stroke is one unit and the end dot has radius 1.5. That is the one departure from the skill’s chart spec.
Colour is state
Section titled “Colour is state”Colour marks only the two states that need a look:
--state-active: a run in flight--state-bad: a failed verdict, a stale snapshot or an unreadable source
The normal case, ready, is plain ink. Links are ink with a quiet underline,
so blue always means a live run. Every coloured value is also a word, so the
page never relies on colour alone.
The tile’s state mark is the one non-text use of a state token (#2477). It
is a dot, coloured through the same .active and .bad rules, with the
reasons in its tooltip. The ask was a tinted tile background. A fill would
break the data-ink rule above, a per-project hue would carry identity rather
than state, and identity is already carried by the linked selection between
a tile and its notices.
The dataviz palette validator chose this pair. The first design coloured
ready green. Red against that green falls in the colour-blind floor band in
dark (deutan ΔE 7.4) and fails in light (5.5), and the dark green was outside
the lightness band. Blue and red pass every check in both themes (ΔE 26.7
dark, 29.4 light). The state tokens are separate from --accent because a
dashboard theme can replace the accent: forest makes it a green that is
outside the dark lightness band. No theme overrides the state tokens, and the
page test runs the validator against every theme.
Both themes
Section titled “Both themes”The server substitutes only the design tokens into the page, so the page paints its own ground, ink and font. Before #2074 it did not. The dark theme drew light text on the browser’s white canvas, in its default serif font, and the light theme was readable only because its ink happens to be dark. The page test now checks for the ground, and checks that every state colour and sparkline ink clears 3:1 on it in every theme, dark and light.
What feeds it
Section titled “What feeds it”Every figure comes from a declared data contract that the CLI also emits. No figure is computed only in the page.
every fleet machine the machine serving the page ─────────────────── ──────────────────────────── project-live.js ─► audit.js snapshot ─► $BOB_FLEET_SNAPSHOTS/<id>.json ──┐ (on a timer, #2067) │ ├─► fleet-feed.js ─┬─► /fleet.json project-live.js (this machine, read live) ─────────────────────────────────┘ bob-fleet-feed/1 └─► event: fleet on /warp/events- One reader.
scripts/fleet/project-live.jsreads one project from disk. It returns the warp run (session, phase, start, tokens), every other session throughbob-session-state/1(Every session), the last commit, and the lastbob readyverdict. The machine serving the page calls it for its own projects. Every other machine calls it insideaudit.js snapshot. A live row and a remote row therefore always come from the same code. - The transport is the snapshot. Each machine publishes a snapshot on a
timer (Fleet Snapshot Timer) to
$BOB_FLEET_SNAPSHOTS. Remote state reaches the page only through that directory. A machine that does not share the directory, or whose timer is not running, shows up as not having published, never as live. The server never pulls over SSH to fill a gap. A down machine would stall every refresh, and a pull on every tab would grow with the number of viewers. - The feed.
scripts/dashboard/fleet-feed.jsmerges the two sources intobob-fleet-feed/1(reference). The server serves it at/fleet.jsonand pushes it asevent: fleeton/warp/events, the stream the/warppage already holds open. The server rebuilds it at most once per refresh interval (refresh_s, default 10 s), and only while a stream is connected, however many tabs are open. It pushes only when the feed has changed: the clock ticking is not a change. A snapshot that lands in the directory reaches every open stream within one refresh interval. - State is derived in the feed (#2477). Each project row carries
state:runningfor a live session,attentionfor a blocked preflight, a dead run, a failingbob readyor a stale snapshot, elseidle. It also carriesreasons[], which names the facts that set it. Each machine carriescounts: {projects, running, attention}. The page renders these values and never re-derives them.stateis part of the push signature, so a project that turns toattentionreaches every open page within one refresh interval (Fleet feed). - So is recency (#2923). Each project row carries
last_active_at, the newest of its last commit and, while a run is alive, that run’s last phase transition, andactive_from, which names the fact that won. It isnullwhen neither is readable, and the grid never reads anullas old. The row also carriesportfolio, the manifest’s_meta.portfolio, so a parked or archived project can step aside. The reader adds the run’slast_transition_atandportfolioOf(manifest), andaudit.js snapshotcarriesportfolio, so a remote row has it too. Both keys are part of the push signature, so a project that moves re-sorts every open page. - So is repo identity (#2929). The reader records each checkout’s
repo(owner/namefrom its origin remote) and where its default branch stands, andaudit.js snapshotcarries both. The feed groups the checkouts of one repo across machines asprojects_by_repo, puts each repo’s combined state, recency and live machine inrepos, and sets each checkout’sdivergence(behind <machine> by N,currentornot-measured) from the SHAs the snapshots carry. Nothing is fetched to say it. - An expanded card fetches its own document (#2924). What a card shows
beyond the feed, including the run’s issue and flight plan, recent commits and
PRs, the queue, health, cost, reports and links, is
bob-project-detail/1(reference). The server serves it at/bob/project/<machine>/<name>.json. It is never pushed: a card fetches it when opened, and the server caches it per project forDASH_DETAIL_REFRESH_S(default 120 s), so only an opened project costs gh reads. A project on another machine is built from its snapshot’s fields, and the forge parts are read on the serving machine. When the forge is down, the last good document is served with its age and the gh reason.
The honesty rules
Section titled “The honesty rules”The page’s first job is not to lie about state. That rule dates from #1548, “the page never lies about a dead loop”. #1737 extended it across machines, and the feed carries it in its data:
- Every machine row says where its data came from and how old it is. It is
either
live(read now), asnapshotwithage_s, astalesnapshot (older thanstale_after_s, the same 24 h windowaudit.js viewuses), or no snapshot at all, with the reason. - Not measured is not zero. A value the source did not record is
nulland renders asnot measured. It never shows as0tokens, anidlephase or a blank commit. A snapshot published before #2069 has no warp data, so its projects shownot measuredfor them. - “No run” is a measurement.
session: nonemeans the state file was checked and no run exists.session: stalemeans a state file exists but the engine’s own liveness probe says the run is dead. The card shows where the run stopped, never that it is running. - Elapsed time is as of the snapshot. A remote run’s
elapsed_scounts up to when that machine read it, not up to now. Age and elapsed time are shown together and never added. - The registry decides the fleet. Machines come from
machines.json. A snapshot from an undeclared machine is ignored, and a declared machine with no snapshot is still listed.
The regions
Section titled “The regions”| Region | What it means | Fed by | Status |
|---|---|---|---|
| GitHub quota | One line: what is left and when it resets, who spent the last hour, rate-limited answers this week against a target of 0 | /fleet.json: each machine’s quota (bob-forge-quota/1), read from the files the gh shim keeps |
Built (#3190): see GitHub quota |
| Needs you banner | The most urgent thing waiting on Paul, in the handoff record’s words: the count, the one-line text, why it matters, the command and the todo link, above every pane; a drained flight plan reads waiting on you |
/notices.json: the todo and blocked notices, each carrying its handoff record from the fleet feed’s handoffs |
Built (#3058): see Needs you banner |
| Active now | One compact card per project that is running or needs attention on any machine, then the most recently active fleet-wide, above the machine sections (#2928). Each card names its state and machine and expands in place; a stale snapshot’s project shows only as attention (stale); a project here keeps its tile in its machine section, and no head counts twice |
The fleet feed’s active_now[] ({machine, name} references, DASH_ACTIVE_NOW_MAX, default 6); the expanded card from /bob/project/<machine>/<name>.json |
Built (#2928): see Active now |
| Machine and project grid | By project (the default, #2929): one tile per repo with a row per machine that has it checked out, the combined state, behind <machine> by N on a lagging checkout, and a card that lists every checkout; a checkout with no repo keeps its own tile under its machine. By machine: one compact card per project on every machine (#2925): a head with the name, state mark and N need you, then now, last, health and spend 30d, under a machine header that shows source, age and counts. Each card expands in place into the detailed view, and #card=<machine>/<name> opens one expanded. A stale snapshot’s tiles are dimmed. Each tile carries a state mark, the tiles sort by recent (default) / name / state / tokens / spend 30d, and only the serving machine opens by default. Each machine shows 6 / 12 / all tiles, never hiding a running or attention tile; parked and archived projects step aside, and a push never moves a hovered or expanded tile |
The fleet feed (state, reasons, counts, last_active_at, portfolio, issue, chunk, repo, divergence, projects_by_repo, repos); the expanded card from /bob/project/<machine>/<name>.json (bob-project-detail/1) |
Built (#2070): /bob. At-a-glance controls (#2477). Opens on what is active (#2923). Compact and expanded card (#2925). By project (#2929) |
| Session card | One run’s phase graph and history, bob warp viz as a card |
/warp/state.json, /warp/events |
Exists (#1552). Each grid tile links to its card (#2070) |
| Notices | Only what needs Paul: todos awaiting completed, blocked runs, decisions waiting, stable releases ready to bob update. Each notice carries its exact command or link and disappears when its source clears. It filters by kind, machine and priority, and follows the page-wide project selection (#2477, #3039) |
/notices.json (bob-notices/1): forge searches, the fleet feed’s preflight, tags and .bob-version |
Built (#2071): the Notices column on /bob, with a daily did-you-know from bob tour below it (#2077). Bridge questions are not measured until the worker exposes a read endpoint |
| Activity | Every project by its last commit, newest first: name, a 30-day commit sparkline, branch, a dirty marker, age and subject. One row per project at its freshest checkout; ages as of each source’s read; no activity data listed last under a divider |
The fleet feed’s last_commit, branch and activity ({dirty, commits_by_day}, recorded by audit.js snapshot) |
Built (#2154): see Activity |
| Cost and churn | Tokens and USD per project, run and merged requirement. Fix-to-feat ratio, defect recurrence, flaky reruns, CI duration and releases, all as sparklines | /series.json (bob-series/1): token log (#1585, #1806) priced by the cost model (#1805), git history and tags, the state file, quarantine issues, gh run list |
Built (#2072): spend and token rows on each tile, and the churn strip on /bob |
| Reports | Tick digests, session summaries and status reports of the last 14 days, newest first, linked to their issues; each digest’s markdown rendered in place | /reports.json (bob-reports/1): $BOB_HOME/fleet/ticks/, each snapshot’s last_tick, session-report and status-report issue searches |
Built (#2073): the Reports pane on /bob |
| Briefings | The latest briefing — Needs you first, then Done, Doing, Will do, each head with its count, long lists folded, every item linked — and the index of past ones; read on a phone, so one column at 400 wide. On /bob, the Briefings pane under Reports lists recent portfolio briefings and open area briefs, newest first, each with N need you, the newest portfolio briefing marked latest, a brief regenerated over 7 days ago saying its age |
/briefings.json (bob-briefings/1): the stored briefing/1 documents under $BOB_BRIEFINGS, else $BOB_HOME/briefings (what bob brief --write / --send stores, #2243 / #2245; kept on a schedule by #2246). The pane reads /bob/briefings.json: the same index plus the open brief-labelled area-brief issues (bob brief --area) |
Built (#2244): its own page /briefings, linked from the /bob header and bob viz --page briefings; see Briefing → The page. The Briefings pane on /bob (#2926): see Briefings |
| Design | Tufte pass: data-ink, small multiples, no decorative colour, legible in both themes | scripts/dashboard/templates/design-tokens.js (the dense type scale, --hairline, --state-active, --state-bad, --viz-line, --viz-point) |
Built (#2074): see Design. tests/test-bob-viz-page.js enforces the stylesheet rules in every theme |
A region whose source has no data says so in the same words as the feed:
not measured, never an empty chart that looks like a zero.
This table is the page’s pane registry. A new region adds a row here and cites the design checklist: it copies the checkboxes into its requirement and ticks them before merging. It is built on the page controls: it collapses, and its lists sort and fold.
See also
Section titled “See also”- Fleet Feed: the
bob-fleet-feed/1contract, served and pushed - bob viz Dashboard Service: the always-on server, installed with
bob viz --install-service - Fleet Snapshot Timer: what keeps remote rows fresh
- Warp-drive internals: warp viz: the single-session page this frames
- Project Monitor Exposure Posture: why tailnet-only