BoB Control
BoB Control
Section titled “BoB Control”BoB Control is the one place the operator works from: every project, every running session, and the things waiting on him, behind one navigation. Capability #3812. This page covers the frame (#3813), its navigation (#3914) and each screen; each later requirement adds its section here.
What it is
Section titled “What it is”Control is one HTML page the dashboard server serves at /control. Its
navigation has four entries, the same on every screen, followed by
the command bar:
| Entry | What it shows |
|---|---|
| Now | What needs the operator: the asks that block work, five at most, with the rest folded; then what is running; then, only while a machine fact needs attention, what is wrong. It is the page /control opens on. |
| Projects | Every registered, active project by name. Each name opens that project’s page. |
| Fleet | What needs attention across the machines, each problem with the command that fixes it; a card per machine; capacity; the fleet health tables; then the fleet’s Notices, Reports and Briefings, as bob viz shows them. It has no project tiles: Projects and the project pages show those. See The Fleet page. |
| Costs | Will I hit a limit this month, where the money went, whether it was worth it, the outliers and every limit, then the GitHub quota line. It has no project tiles: a project’s cost and churn row is on its own page. See The Costs page. |
Three more screens are opened from those pages and sit under an entry:
| Screen | Opened from | Sits under |
|---|---|---|
| Project | A name on the Projects page. It is the project’s own page: a summary line, its asks, sessions, actions and tabs, beside its terminal. | Projects |
| Session | A row of a project’s Running section: one tmux session, with the run in it or none. Its page is one running state machine: what it is working on, its states, its status and tabs, beside its terminal. | Projects |
| Ask | An ask’s row, at /control?ask=<owner/repo#N>. It is one ask with its steps, beside a terminal. |
Now |
The navigation marks the entry of the page that is open. On a screen that sits under an entry, that entry is marked instead.
The navigation is one row (#4025): the entries, then the crumbs of where you
are, then the command bar. A Project screen adds the project’s name, linked to
its page. A Session screen adds the project and then the session’s tmux name,
linked to that session page, with its machine beside it (on farm-01) when the
session is on another machine. The last crumb is the page that is open:
BoB Control Now Projects Fleet Costs › bigbrain › warp-bigbrain on farm-01 Find a commandThe row stays on one line at desktop width. At phone width a middle crumb is shortened with an ellipsis first; the open page’s crumb is never cut.
The reference design for these pages is
docs/design/bob-control-mock.html:
a static mock of Now, a project page, a session and an ask. Open the file in a
browser to click through it.
Nothing new listens on a port. /control is on the same server, the same port
and the same bind as /bob and /warp, so it is reachable on the tailnet and
nowhere else (exposure posture). The browser and
the desktop app load the same address and get the same page.
The three jobs, in order
Section titled “The three jobs, in order”Control is arranged around what the operator sits down to do, in the order he does it (cap #3913):
- What needs me now? The few things only he can do, ranked by how much work each one holds up, each doable from where it is shown. This is the top of the Now page and the Ask screen it opens.
- What is running, and do I need to step in? Every live session in plain words, with whether it looks stuck. This is Running now, under the asks.
- Work on a project. The project’s page, laid out as the flow of the
work: start, check, ship. Every action is a
bobcommand shown on its button, run in a terminal beside it. This is the Project page.
The order is the page order. A job lower on the list never pushes one above it down the page.
Control also teaches BoB. Every button shows the command it runs, the project page suggests the next step for the project’s state, the command bar finds a command from plain words, and commands never used are marked.
What each page leaves out
Section titled “What each page leaves out”Each page shows one job, and leaves out what belongs to another:
| Page | Shows | Leaves out |
|---|---|---|
| Now | The asks that block work (five at most, the rest folded), what is running, and a Something is wrong line only while something is | Cost and churn, the GitHub quota, reports, briefings and the per-machine project grid (they are on Fleet and Costs); a project’s actions; an ask’s steps |
| Projects | Every registered, active project by name | Status detail; each name opens the project’s page |
| Project | One project: a summary line, its suggested next step, its asks, sessions, actions and tabs, beside its terminal | Other projects; the fleet |
| Session | One run: what it is working on, its stepped state row, its status, its tabs, Steer this run, and its terminal | Everything outside that run; the interactive graph (bob warp viz keeps it) |
| Ask | One ask: its steps as cards, a terminal only when a step is a command, and how it finishes | Other asks; the issue’s comment thread (the original issue is one link away) |
| Fleet | Needs attention, a card per machine, capacity, the fleet health tables, then Notices, Reports and Briefings | Project tiles and per-project tables (Projects and the project pages have them); what needs the operator now, which reaches the Now page only as a Something is wrong line |
| Costs | This month against each budget, spend by project and kind, cost per merged requirement and per run, outliers, limits, the GitHub quota line | Project tiles: each project’s cost and churn row is on its page |
How the asks are ranked
Section titled “How the asks are ranked”An ask comes first when it holds up work BoB could be doing. bob asks gives
each ask an unblocks tier:
it gates an item of a flight plan with a live run (running), an item of a
plan with no run (ready), an approved issue on no plan (approved), or
nothing (none). The asks are ordered by tier, then by how many open items
each gates, then by priority, then oldest first. The Now page shows the first
five that block work, in that order. Each ask appears once.
Where things are defined
Section titled “Where things are defined”| What | Reference |
|---|---|
| The actions a project’s page offers, and how to add one | Control actions table |
| What an ask unblocks, and the order of the asks | bob asks |
| When a running session is stuck (60 minutes quiet) | Fleet Feed |
bob control and bob control runs |
CLI reference |
Control holds no state of its own: it shows what lives in issues, tmux, state
files and the fleet feed, and acts through the same bob commands a shell
would. See The frame holds no state.
Open it
Section titled “Open it”bob control # prints the /control URL and opens itbob control --tailnet # served on this machine's Tailscale addressbob control --json # one bob-viz-url/1 document for /control; opens nothingbob control starts the dashboard server when none is running and reuses a
fresh one, exactly as bob viz does.
How an address picks a screen
Section titled “How an address picks a screen”| Address | Screen |
|---|---|
/control |
Now |
/control?view=projects |
Projects |
/control?view=fleet |
Fleet |
/control?view=costs |
Costs |
/control?project=<name> |
Project |
/control?project=<name>&session=<tmux name> |
Session: what runs in that tmux session |
/control?project=<name>&session=<tmux name>&host=<machine> |
Session, for a tmux session on that fleet machine, with its terminal there |
/control?project=<name>&session=<tmux name>&checkout=<worktree> |
Session, for one in a linked git worktree of the project |
/control?ask=<owner/repo#N> |
Ask |
A session is addressed by its tmux session’s name (#4026). tmux names can
repeat across machines, so the address carries host=<machine> whenever the
session is not on the machine serving the page. The server works out what runs
inside the tmux session and draws the matching page:
| What is in the tmux session | The page |
|---|---|
A run: warp-drive, or a machine on the shared engine declared in scripts/lib/session-machines.json (decompose and do today) |
That run’s session page, its kind in the heading. A machine added to that file has a session page with no page code of its own (#3815). |
| A groom, plan or ritual run, which has no states of its own to show | Its terminal, labelled with the run’s kind. |
| No BoB run, only a shell, in one of the project’s checkouts | Its terminal, labelled terminal. |
| Nothing: no tmux session of that name is open in the project | ended, with a link back to the project’s page. The status is 200, not 404. With &checkout=<worktree>, it also says whether that worktree is kept or, merged, can be removed (#4028). |
Which tmux session a run is in is read as bob warp attach reads it: for a
warp run, the session its last segment handoff spawned, else the one its state
records, else the one its process descends from, else the newest segment, else
the session bob warp start made; for any other run, the session its process
descends from. So two runs in one project, in two tmux sessions, have two
pages. A warp run that moves to a new tmux session at a segment handoff keeps
one address: an earlier tmux session of the run, warp-seg-<stem>-<N> or the
warp-<stem> launcher, redirects to the session the run is in now, whether the
old one is still open or gone. When the page’s terminal ends, the page asks for
its address again and reloads when it has moved or ended, so it follows the
run.
Worktrees: a session belongs to its project
Section titled “Worktrees: a session belongs to its project”A session is known by its host, its checkout and its tmux name, and it always
belongs to its real project (#4041). A checkout’s project is resolved from its
main repository (git rev-parse --git-common-dir, through
scripts/lib/project-name.js), never from its folder name. So a warp run
started in ~/projects/bigbrain.worktrees/control-4039 is a bigbrain run:
| What | For the main checkout | For a linked worktree |
|---|---|---|
| The stem tmux names are built from | <project> |
<project>-<worktree> |
bob warp start’s session |
warp-bigbrain[-fp-N] |
warp-bigbrain-control-4039[-fp-N] |
| A segment’s session | warp-seg-bigbrain-<N> |
warp-seg-bigbrain-control-4039-<N> |
| The dashboard | bigbrain’s, on bigbrain’s ledger port | the same server: asked to serve a worktree, the server serves its main repository and reads the worktree’s sessions with every other checkout’s |
| The manifest preflight reads | provisions/bigbrain.json |
provisions/bigbrain.json |
| Where Control lists the run | bigbrain’s Running rows | bigbrain’s Running rows, the row saying in control-4039 |
The stem keeps the two checkouts’ runs apart: a run in the main checkout and
one in a worktree never share a tmux name. The address of a session in a
linked worktree carries the checkout, &checkout=<worktree name>, and the
session page’s crumb names it (warp-bigbrain-control-4039-fp-4039 in control-4039). The tmux name alone finds the session; the checkout is there
for the reader. An address with no checkout still works.
The project page lists the project’s Worktrees under its Running rows when
it has any linked worktree: one row per checkout on each host — the main one
first — with its name, its branch (detached for a detached HEAD), whether
that branch is merged into the default branch (merged, not merged, or
merge not measured), its path and the tmux sessions open in it. A branch
whose commits are all in the default branch counts as merged, so a worktree
made from the default branch with nothing committed yet reads merged. This
machine’s checkouts are read live (git worktree list); another machine’s come
from its snapshot’s worktrees, as old as that snapshot.
tests/test-worktree-project.sh runs a warp session in a worktree and checks
its tmux name, its dashboard and its place in the project’s session list.
An old address names a kind, session=warp-drive, decompose or do. It
redirects to the tmux session running that kind. When no run of that kind is
going, the page shows that kind’s states with a line saying none is running;
when the run is in no tmux session, a line says so and the page has no
terminal. For another machine (host=), an address naming a kind still draws
that kind’s page from this machine’s checkout, and an address naming a tmux
session is that session’s terminal there.
Each project’s dashboard server draws that project’s sessions. A session
address for another project on the same machine is answered with a redirect to
that project’s own /control. When that project’s dashboard has no port on
this machine, the frame shows the project’s page and a line that says how to
start it.
An address that names a project or a view that does not exist still returns the frame, on the nearest screen that does exist, with a line that names what was not found. The response status is 404.
| Request | Message |
|---|---|
| A project no machine in the fleet feed has | No project named "<name>" on any machine in the fleet feed. |
| A session that cannot be a tmux session name | "<name>" is not a tmux session name: letters, digits and _ . @ + -, at most 100. |
| A session with no project | A session screen needs its project: /control?project=<name>&session=<tmux session>. |
A view that is not projects or fleet |
No view "<name>": a view is one of projects, fleet. |
bob viz and /bob are unchanged: they serve the bob viz page on its own,
without the frame.
Issue numbers are links
Section titled “Issue numbers are links”On every screen in the frame (Now, Projects, a Project, a Session, an Ask),
each #N and owner/repo#N is a link to that issue on its forge, and it opens
in a new tab. One shared module, /control/issue-links.js, links them when the
page is drawn and again whenever a section redraws.
A bare #N belongs to the repo that its nearest enclosing section names:
| Where the number is | Its repo |
|---|---|
| Text BoB wrote: an action’s description, the command bar, the never-tried list, a state of a session’s engine | BoB’s own repo (the source checkout’s origin remote). These numbers are BoB’s issues, never the project’s. |
| A section of one project (an ask row, a running row, the project’s panels) | That project’s repo, from its checkout’s origin remote, or the registry’s record of that remote when the project is not on this machine. |
| Anywhere else on a project’s, session’s or ask’s screen | The repo of the project the screen is about. |
owner/repo#N always names its own repo. When the repo cannot be resolved, the
number stays plain text with a dotted underline, and its tooltip says why. It is
never linked to a guessed repo. Numbers inside code, links, form fields and the
terminal are left alone. The module reads only text and builds only DOM nodes,
so a title that contains markup is shown as text and never runs.
The Now page: what needs you
Section titled “The Now page: what needs you”The Now page opens with the asks that hold up work BoB could be doing. The
section is the element with id needs.
Its heading is <N> things need you. N is the number of open asks, across
every project, whose unblocks.tier in bob-asks/1
is running, ready or approved: the ask gates an item of a flight plan
with a live run, an item of a flight plan with no run, or an approved issue on
no plan. With none, the heading is Nothing is waiting on you.
Under the heading are at most five of those asks, in the order bob asks
gives them: most-blocking first. Each is one row:
| Part | What it is |
|---|---|
| Title | The ask’s title, without a leading TODO:. |
| What it unblocks | The ask’s unblocks.summary, for example Unblocks 2 items in the running beta plan. |
| Issue | The ask’s issue number, #8, linked to the issue on its forge (the url bob asks read from it) and opened in a new tab. An ask with no known address shows the number as plain text, and its tooltip says why. The folded rows carry it too. |
| Project | The project the ask belongs to. |
| Minutes and place | about 15 min and where the ask is done, when the ask states them. |
| Wait | How long the ask has been open: waiting 9 days. |
| Do it | Opens the ask’s Ask screen. |
The first row’s Do it is filled. It is the only filled button on the page: the one thing to do next.
The other asks are not listed. They sit behind folded lines that open in place:
| Line | What it opens to |
|---|---|
<K> more that block work |
The asks past the fifth that block work, in the same order. It is shown only when more than five block work. |
<M> more waiting, nothing blocked by them |
Every other ask, grouped by project. Each links to its Ask screen. |
M counts the asks that block nothing, the asks whose forge could not be read (each says so on its line), and the asks held behind the attention budget. With M at zero the line is not shown.
Each ask is on the page once: as a row, or as one line of a folded list.
When the asks cannot be read, the section says so and gives the reason, for
example Asks unavailable: no project could be read (2 tried): forge rate-limited. The navigation and the rest of the page are unaffected.
The section is in the page as served when bob asks answers within four
seconds. When it takes longer the section says Reading the asks… and fills
itself. The page reads the document again every minute, and a folded list that
was opened stays open.
The section is drawn from the asks document,
GET /control/asks.json, by scripts/dashboard/control-now.js.
tests/test-control-now.sh starts the real server with a fixture of eleven
asks and reads the heading, the rows, the folded lists and the unavailable
case.
Running now: what is running
Section titled “Running now: what is running”The Now page’s second section, the element with id running, has one row per
live session across the fleet feed: every warp
run, every decompose or do session, and every groom, plan or ritual run, on
every machine whose snapshot is fresh. That includes a run in a linked
worktree, which New session starts while a warp run holds the main checkout:
its row names the worktree (#4031).
A row reads, for example:
bigbrain — warp run on flight plan #3928item 4 of 9 · testing · MacBook Pro (this machine) · started 14 h ago · last activity 2 min ago| Part | What it is |
|---|---|
| Project | The project the session runs in. |
| What it is | In plain words: warp run on flight plan #N, warp run on #N for a run with no plan, breaking #N into requirements for a decompose run, working on "<question>" for a do run, groom run. |
| Item | item X of Y for a run on a flight plan, once the run has crossed its first item boundary. |
| Phase | The session’s current phase. |
| Host | The machine it runs on. |
| Started | How long ago it started. |
| Last activity | How long ago a warp run’s project last moved: its last commit or the run’s last phase transition. Other sessions say last activity not measured. |
| Open | Goes to the session’s screen. For a session on another machine, the screen’s terminal attaches there. |
| Command | For a warp run, the command Open stands for: bob warp attach <project>, and the machine to run it on when that is not this one. |
A run with no activity for more than 60 minutes is marked
stuck: quiet for 3 h. The threshold, the activity source and the clock the
quiet time is measured at are in the
fleet feed reference.
A tmux session that was opened in a project’s checkout on this machine but
belongs to no running session is not a row. It is listed under a folded line,
<N> other terminals, with its project and its tmux name, linked to its own
session page, its terminal (#4026). The tmux session a
run lives in is the run’s own and is not listed there either.
With nothing running the section says Nothing is running and links to the
Projects page.
The section’s document is GET /control/running.json
(bob-control-running/1), built from the feed by
scripts/dashboard/control-running.js. The page reads it again every half
minute. tests/test-control-running.sh starts the real server with two
machines’ snapshots as the feed and a stub tmux, and reads the document and
the section: an active run, a run quiet past the threshold, a bare tmux
session, and nothing running.
Something is wrong: only when something is
Section titled “Something is wrong: only when something is”Machine facts are on the Now page only when one needs attention. The page’s
third section, the element with id wrong, is headed Something is wrong and
has one line for each of these that is true:
| Line | It is shown when | Command | Links to |
|---|---|---|---|
| Host | The host verdict of bob status is not ok: this machine’s BoB install is not what was deployed, or the check could not run. |
The next command bob status names for the host, shown as the fix. bob doctor when it names none. |
The machine grid |
| Forge | A machine’s snapshot says the forge is degraded or unreachable, or its gh shim took a rate-limited answer in the current week. |
bob forge health |
The GitHub quota line |
| Backup | A declared backup is stale. The line names each project. | bob status --scope fleet |
The machine grid |
| CI queue | A CI queue alarm is raised. The line names each project, the kind of alarm and how long its oldest job has waited. | bob status --scope fleet |
The machine grid |
A line reads, for example:
GitHub rate-limited BoB 96 times this week (target 0). check with bob forge health CostsIt states the fact in plain words, shows the command that checks or fixes it, and links to the page that shows the same fact in full: Costs for the forge (its GitHub quota line), Fleet for the rest.
With none of the four true, the page has no third section: no element, no heading. The quota is always on Costs, and reports on Fleet.
The lines are read from one bob status --json document. The server runs that
command at most once a minute. bob status grades from what is already on
this machine: the host probe, which reads local files, and the cached fleet
snapshots. Neither it nor the page calls the forge. Two things follow:
- The forge, backup and CI queue lines are as old as the newest snapshot of the machine that reported them. The snapshot timer publishes one every hour.
- A machine whose snapshot is stale, or that never published one, is not read. What it last said is not a fact about now.
When bob status itself cannot be read, the section has one line that says
so, with bob status as its check. A failed read is never shown as a healthy
page.
The section’s document is GET /control/wrong.json (bob-control-wrong/1):
a lines array whose elements carry kind (host, forge, backup,
ci-queue or status), text, does (check or fix), command and
href. It is built by scripts/dashboard/control-wrong.js. The page reads it
again every minute, and adds or removes the section as the answer changes.
tests/test-control-wrong.sh starts the real server over the real
bob status in a sandbox: a healthy fixture, a fixture with every condition
raised and one whose status cannot be read.
The Fleet page: are the machines able to do the work?
Section titled “The Fleet page: are the machines able to do the work?”The Fleet page (/control?view=fleet, #4035) answers one question: can my
machines do the work, and if not, what do I run to fix it? It is drawn from
what is cached on this machine: the fleet feed, every machine’s newest
snapshot, machines.json and the running list. It makes no forge call.
/control/fleet.json is the same page as a bob-control-fleet/1 document.
Top to bottom:
- Needs attention. One line per problem, worst first: urgent, high, medium, low. When there is none, the section is not drawn and the page says All machines are healthy.
- Machines. One card per machine
machines.jsondeclares. - Capacity. Live sessions per machine against its declared limit, and the machine a new session would go to.
- Fleet health. CI locus, dependency advisories, backups and harness defects. Each table has one row per reporting machine, worst first. A clean table is folded.
The fleet’s Notices, Reports and Briefings follow.
Needs attention
Section titled “Needs attention”| Problem | Severity | Command |
|---|---|---|
The BoB install is unusable (bob doctor hard drift) |
urgent | bob doctor |
| A declared CI runner’s process is not running | urgent | runner.sh service start --machine <id> (Copy) |
| The disk is 98 % full or more (95 % and more is medium) | urgent | bob reap |
| The machine has not reported within the stale window, or never has | high | audit.js snapshot --install-timer on that machine (Copy) |
| A CI queue alarm is raised | high | bob status --scope fleet |
| A declared backup is stale | high | bob backup run --env <env> in that project |
| A secret the operator profile declares is missing (else a credential the inventory declares) | high | bob secrets check (else cred-check.js, Copy) |
| A credential is due for rotation | medium | cred-check.js due (Copy) |
| The machine is behind source | medium | bob update |
| The BoB install differs from what was deployed (advisory) | medium | bob doctor |
| Remote Login is off (macOS) | medium | sudo systemsetup -setremotelogin on (Copy) |
| The host layer (Fleet, thefarm) reports drift | low | <provider> status (Copy) |
A routed bob command has a Run button, or Fix… when it changes
something: Fix… shows the command and the machine and runs only on Run it.
Both go through POST /control/project/run under its rules: on this machine
with no project when the command needs none, otherwise in a project checked
out on that machine, typed into that machine’s terminal. A command the routing
table does not carry has a Copy button and names the machine to run it on.
A machine whose snapshot is stale, or that never published one, has one line: that it has not reported. Nothing else it last said is listed as a problem, because it is not a fact about now.
Machine cards
Section titled “Machine cards”Each card shows whether the machine is online, its role, the BoB version it
runs and how far behind source, the bob doctor verdict, its live sessions
(each linked to its session page), its CI runner and queue, load and disk, its
model-route key, and when it last reported. Load, disk, Remote Login and the
runner’s process come from the snapshot’s summary.machine
(scripts/fleet/machine-facts.js). A value no snapshot carries reads
not measured, never zero.
A stale card is grey and dashed, labelled stale, and says that what it shows is what the machine last said. It is never shown as healthy.
Capacity
Section titled “Capacity”A machine declares how many live sessions it is meant to carry with
session_limit in machines.json. The capacity table counts each reporting
machine’s live sessions against it. A new session would go to the machine
with the most room. When no machine declares a limit, the page says so: a new
session then starts on the machine its project is checked out on.
The Costs page: will I hit my limit this month?
Section titled “The Costs page: will I hit my limit this month?”The Costs page (/control?view=costs, #4036) answers: what am I spending, on
what, is it worth it, and will I hit a limit before the month ends? Its first
line answers the last question, and at 1440×900 the first screen holds that
answer and the This month table. /control/costs.json is the same page as a
bob-control-costs/1 document. Top to bottom:
- This month. One row per route key
machines.jsondeclares (routes.<route>.monthly_limit_usd): spend so far against its budget, the projection to month end and the day the budget runs out at the current rate. This machine’s key is read withbob route statusat most once every 10 minutes; another machine’s says where to read it. A row is amber when the projection passes the budget and red when the budget runs out before the month ends. The Claude sessions the token ledger records, priced bytoken-report.js, sit beside them with no budget. - Where it went. Spend by project and by kind of run (warp-drive, decompose, do, groom, tick, interactive), this week against last.
- Is it worth it. Dollars per merged requirement and per run as 30-day series, the fix-to-feat ratio, and this week’s five most expensive runs, each linked to its repo’s session reports on the day it ended.
- Outliers. Runs in the last 30 days that cost more than 3× the median
run of their kind, and live runs with no commit or phase move for 30
minutes (
IDLE_SPEND_MINUTESincontrol-costs.js), each linked to its session page. - Limits. GitHub Actions minutes against the included quota, the GitHub API quota with the week’s rate-limited answers, and each route key’s limit, each with when it resets.
The projection carries the month-to-date average to month end. The run-out
day spends what is left at the current rate: the last 7 days of the token
ledger’s runs, or of a route key’s own readings. A route key reports one
running total, so costs-load.js keeps one reading an hour in
$BOB_HOME/.route-readings.json; until they span a day, the key’s rate is the
month-to-date average. A key spending slower than its month so far can be
amber without being red.
The same document drives the cost alarms (#4037): bob tick and
bob brief --send send one bob notify when a key turns amber, one more when
it turns red, and one per runaway run, each once
(scripts/costs/notice.js, deduplicated in $BOB_HOME/.cost-notices.json). See
Farm tick.
Each ledger record names its kind (#4036): warp-drive, or tick for an
unattended run. A record written before the field reads as warp-drive, the
only writer there was. decompose, do, groom and interactive runs write nothing
to the ledger yet, so their rows read not measured with that reason. Any
figure that was not measured says so and why, never zero.
The Projects page
Section titled “The Projects page”/control?view=projects lists every registered, active project: each entry of
projects.json that is not marked active: false. A project that is
registered and not checked out on this machine is listed too.
A project with a live session or an open ask is listed before a project with
neither, and its row says which: session running, 2 open asks, or both.
Within each group the projects keep the registry’s order.
| Fact | Where it is read |
|---|---|
| A live session | The project’s checkout on this machine, read when the page is requested; and the fleet feed, for a machine whose snapshot is not stale. |
| Open asks | The count bob asks reports for the project. When bob asks has not answered in time, the page is served with no ask counts. |
Parked and archived projects
Section titled “Parked and archived projects”A project whose manifest records Park or Archive (_meta.portfolio, written by
bob ritual decide) is not working, so it leaves the working list:
- Parked projects are in a folded Parked group below the list.
- Archived projects are listed only behind a folded Show archived
toggle. An archived project is
active: falseand has leftprojects.json, so this group is read from the operator repo’s manifests. Its page still opens, with its decision.
Each row has a Revive button and a choice of Invest or Maintain. Revive
runs bob ritual decide <project> <invest|maintain> --apply under the
Portfolio rules: it asks first,
and for an archived project it un-archives the GitHub repo.
Parked and archived projects are off the working pages:
| Page | What happens to a parked or archived project |
|---|---|
| Now: what needs you | Its asks are left out. Its own project page still lists them; /control/asks.json counts them as stepped_aside. |
| Now: Running | A live session in it is still listed, last, with a note that the project is parked (or archived). It is never marked stuck. |
| Fleet | A live session in it is listed on its machine’s card with the same note, and is not counted in Capacity. A fix that runs in a project never picks it. |
Sweep: deciding the directories the sweep found
Section titled “Sweep: deciding the directories the sweep found”The portfolio sweep (#3609)
lists every directory under ~/projects and ~/Sites on every fleet machine
in the operator repo’s portfolio.json. Below the groups, the Sweep
section lists the rows bob ritual sweep status reports as undecided. Each
row shows its id, its project when it has a manifest, and the machines it is
on, with one button per outcome the sweep takes for it:
| The row | Its buttons |
|---|---|
| A managed project (it has a manifest) | Invest, Maintain, Park, Archive |
| A directory with no manifest | Invest, Maintain, Park, Not a project |
A button shows bob ritual sweep decide <row> <outcome> and runs it after
Run it. A decision is only recorded in portfolio.json. Apply… carries
the recorded decisions out. It first shows the report of
bob ritual sweep apply, which changes nothing, and runs
bob ritual sweep apply --apply only after Run it. Exit 1 (a decision
blocked or failed) is shown as partly done, with the output naming each one.
Scrap is not offered here. Its approve-the-exact-list flow stays in the
terminal: bob ritual sweep decide <row> scrap, then bob ritual sweep scrap.
The section is absent when nothing is undecided, or when there is no
portfolio.json yet.
The buttons send POST /control/sweep: { row, outcome } or
{ apply: true }, with preview or confirmed. The server runs the sweep’s
engine as a process on this machine, under the same rules as a portfolio
decision. A row that is not in portfolio.json is 404, a row already decided
is 409, scrap is 400, and a run without confirmed: true is 409 with its
command. Each run is logged as sweep-decide or sweep-apply.
tests/test-control-portfolio.js also covers this section over a fixture
portfolio.json: the rows and their outcomes, the refusals, a decision being
recorded, and Apply’s report coming before its run. In headless Chrome it
parks a project from its page and sees it move to Parked and off the Now
page’s asks, then revives it from Projects.
New project: starting one from a form
Section titled “New project: starting one from a form”The Now and Projects pages both have a New project button (#4032, cap #4023). Its form asks for:
- the project’s name;
- the host it lives on, which can be any machine
machines.jsondeclares, with this one selected first; - where the repo comes from: a new private repo, an existing
owner/repo, or a directory already on the host; - a few sentences of vision.
Show command checks the form and shows the command it builds. Nothing runs at this point. The server refuses a request before anything runs, and the form’s status line says why, when:
- the name is not a safe directory name (letters, digits and
_ . -); - the name is one the fleet feed or the registry already has;
- the host is one
machines.jsondoes not declare; - the
owner/repois not one; - the directory is not a full path (
/…or~/…).
The command is always a real bob ready invocation that the server builds from
the form, never text from the request:
| Repo | Command |
|---|---|
| new private repo | bob ready <projects_root>/<name> --name <name> |
existing owner/repo |
bob ready owner/repo --name <name> — bob ready clones it into the host’s projects_root |
| directory on the host | bob ready <dir> --name <name> --new |
With a vision, the text is piped in on stdin:
printf '%s\n' $'…' | bob ready … --vision -. It becomes the project’s first
docs/vision.md, and /vision is its Suggested next until it is refined (see
bob ready --vision, #4033). projects_root is
the host’s machines.json value, or ~/projects.
Run it opens the host’s own terminal in the form, then types the command
into it, so the output streams on the page. The project has no checkout yet,
so this terminal is not a project’s. It is one tmux session per machine, named
bob-host, with a shell in the home directory. Opening the terminal with
host: true in place of project reaches the same session, and
terminal-attach.js --host opens it on another machine over Tailscale SSH.
The page then asks GET /control/project/new/status?name=<name> every two
seconds. The server answers from the verdict bob ready persists in the
checkout (.claude/.project-ready.json), counting only a verdict written after
this run started:
| Verdict | Page |
|---|---|
| READY | The browser goes to ?project=<name>. bob ready registered the project, so it is in Projects. |
| NOT READY | The page names each step bob ready stopped at, with its detail and fix, and shows Resume. Resume runs the same command again; bob ready is idempotent. |
| none after 20 minutes | The page says to read the terminal, and shows Resume. |
Only a run this server started can be asked about or resumed under a name the
fleet already knows. Who may run one is who may run an action (#3821): a caller
on this machine with its token, or a machine machines.json declares. Each run
is one line of $BOB_HOME/control-runs.jsonl, with action new-project, where
host, and resume: true on a Resume. A refusal logs nothing.
The optional stack, project type and automation level the capability mentions
are not on the form. bob ready infers stack and type, and takes the
automation level from operator.json project_defaults. It has no flags for
any of them.
tests/test-control-new-project.js runs the real server on a throwaway tmux
server and HOME, with a stub bob that persists bob ready’s verdict. It
checks:
- each refusal;
- the preview;
- that a run types into
bob-host, logs one line and turnsready; - that a stopped run names its step and Resume runs it again;
- in headless Chrome, that the form readies a project and lands on
?project=<name>.
The Project page: one project and its terminal
Section titled “The Project page: one project and its terminal”/control?project=<name> is a page of that project and nothing else. The
portfolio grid is on bob viz (/bob); the project’s page has one navigation bar,
at the top, and then the project.
It opens with the project’s name and one summary line:
1 run going · 2 asks waiting here · CI green · on master, MacBook Pro (this machine)| Part | Where it is read |
|---|---|
| Runs going | The live rows of the Running section below. A bare tmux session is a terminal, not a run. |
| Asks waiting here | The project’s open asks in the asks document. |
| CI | The newest workflow run of the checked-out branch, else the newest run of any branch: green, failing, running, cancelled, or no CI for a project that declares no workflow. |
| Branch and host | The branch checked out on this machine, and this machine’s label. |
A part that could not be read says so (asks not read yet, CI unavailable).
The page is served with what was read within four seconds, then reads the
line again at once and every minute
(GET /control/project/summary.json?project=<name>,
bob-control-project-summary/1), so a part that was late fills in.
A project that is not checked out on this machine has no branch or CI reading
here; the line says not checked out on this machine.
Under the summary the page is two columns in a window 1100 px or wider. The left column holds the Running section and five tabs. The right column holds the project’s terminal, which stays in view while the left column scrolls. In a narrower window the terminal follows the left column.
A divider sits between the two columns. Drag it with the mouse to give either column more room: both widths change as you drag. Neither column can be dragged narrower than 18rem, and a double-click on the divider puts them back to half each. The browser remembers the split (in its local storage) and restores it on a reload; where storage is refused the page opens at half each. Below 1100 px there is no divider and the columns stack as before.
The divider works without a mouse. A finger or a pen drags it the same way.
It can be reached with Tab, and screen readers hear it as a vertical separator
whose value is the left column’s share of the width (role="separator",
aria-orientation="vertical", aria-valuenow). With it focused:
| Key | Moves it |
|---|---|
| ← / → | 2 points left or right (with Shift, 10) |
| Home / End | As far left or right as the 18rem minimum allows |
| Enter | Back to half each |
| width it is given, and tmux takes the new number of columns, so what runs in | |
| it reflows to the new width. tmux is given the new size only while the page | |
| is the session’s only client. When someone else is attached to the same tmux | |
| session, from a shell or another page, the page keeps the size tmux has and | |
| never reflows their view; the pane scrolls sideways instead. | |
tests/test-control-split-page.js drags it in headless Chrome against the |
|
| real server and a throwaway tmux server. |
The tabs
Section titled “The tabs”| Tab | What it shows | An empty tab says |
|---|---|---|
| Work | Open by default. The project’s asks, in the Now page’s row form, this project’s only. Then Suggested next. Then the actions. | Nothing to do here right now. |
| Plan | The open flight plan’s items. Each is done, running, next, later or held, and a held item says what it waits on (waits on #43). Then the other open plans, and the approved issues that are on no open plan. |
No open flight plan. |
| Activity | The feed, the most recently updated issues, then the commit log. | No issue has been updated recently. |
| Files | The changed files of the working tree, then the top of the tree. | No changed files: the working tree is clean. |
| CI | The recent workflow runs: result, workflow, branch, how long it took and when. | No CI run yet. |
The open flight plan is the plan of the warp run going in the checkout. With
no run, it is the newest open issue labelled flight-plan.
Suggested next sits above the action groups. It is the one step
bob status --scope project
names for the project’s checkout, so the shell and the page always agree:
| The project’s state | Suggested next |
|---|---|
| A run is going | bob warp status: look at it |
| An ask holds up work | That ask, with a Do it link to its Ask page |
| The project is NOT READY | bob ready |
| An open plan has runnable items and no run | bob warp start --flightplan <N> |
| Approved work and no open plan | bob plan build |
The step’s reason is its description, and its button is filled. A command that
only reads (bob status, bob doctor, bob warp status) runs as soon as you
press Run. Any other command asks first, then runs in the project’s
terminal. With no step, the section says Nothing to do here right now., which
is not an error. When a forge read failed, the section also names what it
could not weigh. When bob status itself does not answer, the section says so.
Work is in the page as served. The other four tabs are read when they are
opened, so loading the page costs the forge nothing for a tab nobody looks at.
/control?project=<name>#plan opens the page on that tab. Each tab’s document
is GET /control/project/tab.json?project=<name>&tab=<key>
(bob-control-project-tab/1), where the key is work, plan, activity,
files or ci. A project that is not checked out on this machine has nothing
to read here, and each tab says so in one line.
Actions
Section titled “Actions”The Work tab lists what the operator starts from the page, in three groups:
Start work, Check and Ship. Each action is a bob command, and the row shows
it in full: a label in plain words, one line on what it does, and the command
text. Seeing the command is the point: the next time it can be typed by hand.
The actions are one declared table, scripts/dashboard/control-actions.json
(bob-control-actions/1, validated by schemas/control-actions.schema.json;
its fields and how to add a row are in the
Control actions table reference).
Each row has a group, a label, a command template and whether it changes
something (mutates). The line on what it does is the command’s tagline
from scripts/bob/commands.json unless the row sets description. Adding an
action is one row in the table; no page code changes. make check-control-actions fails when an action names a command that is not in
the routing table, when two actions share an id, or when an action the page
must offer is missing.
| Button | When | What it does |
|---|---|---|
| Run | The command only reads. | Asks the server to type the command into the project’s terminal and run it. |
| Dry run | The command changes something and has --dry-run (bob warp start, bob warp finalize). |
Runs the command with --dry-run. After it, Run for real… appears, and asks first. |
| Run… | The command changes something and has no dry run (bob plan build, bob warp stop). |
Shows the full command and runs it only after Run it. |
| Dry run… | The row is marked confirm (bob deploy). |
Asks even before the dry run. bob deploy and bob warp stop always ask. |
| Fill in… | The command has a placeholder (<N>, "<question>"). |
Opens a small form below the row. |
The form has one field per placeholder, the command’s own flags from
commands.json with their descriptions, and a live preview of the command
that will run. A placeholder the project’s state can fill is filled: the open
flight plan’s number (the warp run’s plan, else the first open plan) in
bob plan remaining <N>.
Every button runs its command in the project’s terminal, so the command and its output are in the terminal’s scrollback. Nothing runs out of sight: when the terminal is closed, the button opens it first, on the machine the host picker names.
A button sends POST /control/project/run with the project, the action’s id
and the form’s values, never the command. The server builds the command from
the table, so the page cannot run anything the table does not declare:
| Rule | What happens |
|---|---|
| The request names an action, never a command. | A body with any other field is HTTP 400. An action the table does not declare is 404. |
| A value fits what the table allows. | A placeholder value must match its pattern (any one line otherwise), a flag must be one of the command’s own, and every value is shell-quoted. Anything else is 400. |
| A change is confirmed. | A mutating action runs unconfirmed only as its dry run. Its real run, and every run of a confirm action, needs confirmed: true, else 409 with the command it would have run. |
| Who may run it. | The same rules as an ask’s Run button: a caller on this machine with its token, or a machine machines.json declares, arriving on this machine’s loopback or tailnet address. |
| Every run is logged. | One line in $BOB_HOME/control-runs.jsonl: the project, the action, the command, whether it was a dry run, the host, the time and who asked. bob control runs prints it. A refused request adds none. |
tests/test-control-actions.sh starts the real server with a fixture table
and a real tmux terminal. It checks that an action’s row shows its command,
that Run delivers exactly that command to the terminal, that a mutating action
does not run without confirmation, and that each run is a line of the log.
Portfolio: invest, maintain, park or archive
Section titled “Portfolio: invest, maintain, park or archive”Below the tabs, the Portfolio control shows the decision the project’s
manifest records (_meta.portfolio; none recorded counts as Maintain) and
four buttons: Invest, Maintain, Park and Archive. Each runs
the existing command, bob ritual decide <project> <decision> --apply
(#2235). Control has no
path of its own to a decision.
Every button asks first. It shows the exact command and what it does, and
runs it only after Run it. Archive’s confirmation says that the GitHub
repo will be archived, not deleted: it becomes read-only, and Revive
un-archives it. A box beside it adds --keep-repo for a repo someone else
owns, and the command shown changes with it.
The command runs as a process on this machine, where the operator repo holds the manifests, so its exit code is the answer:
| Exit | The page says |
|---|---|
| 0 | Done: the project’s new decision, and the repo archived or un-archived when that happened. The page reloads. |
| 4 | Partly done: the manifest was written, projects.json was not regenerated. The command that finishes it is shown: node ~/.claude/scripts/fleet/projects-derive.js --write. |
| 5 | Partly done: the manifest was written, the GitHub repo was not archived (or un-archived). The finish is gh repo archive <repo> --yes, or the decide command with --repo <owner/name> when the repo is not known. |
| any other | Not done, with the command’s own error. |
A button sends POST /control/portfolio with the project and the decision,
never a command. The rules are those of an action: any other field
is HTTP 400, a project with no manifest in the operator repo is 404, a run
without confirmed: true is 409 with the command, and the caller must be on
this machine with its token or a declared fleet machine. A request with
preview: true returns the command and the confirmation text and runs
nothing. Each run is one line of $BOB_HOME/control-runs.jsonl, with the
action portfolio-<decision> and the command’s exit code.
tests/test-control-portfolio.js runs the real dashboard server and the real
ritual.js decide over a fixture operator repo. It checks the refusals, that
Park writes the manifest and is logged, and that exits 5 and 4 are reported as
partial results with their finish commands.
The dispatch form: build any bob command
Section titled “The dispatch form: build any bob command”The project page and the session page open with Run a bob command, a form
collapsed until you open it (#4030). Its Command list is every row of
scripts/bob/commands.json, grouped by noun. It is read from
GET /control/commands.json when the form first opens, so a command added to
the routing table is in the list with no change to Control.
Choose a command and its form appears:
| Field | Drawn as |
|---|---|
An argument written <a|b|c> in the usage, or a flag whose value is |
A dropdown of those words |
| A switch (a flag that takes no value) | A checkbox |
| A free value, argument or flag | A text input. An argument written [<name>] is optional; every other one blocks Run until it is filled. |
A quoted argument (bob do "<question>") |
A box for the harness text: the prompt the Claude session is given |
| Other arguments | Words added to the end of the line, for anything else the usage takes |
Will run: shows the exact line the server will run, updated as you type.
Run runs a command that only reads at once. A command that changes
something asks first, showing the line; one with a --dry-run also offers
Dry run, which runs without asking. A run with Other arguments always asks
first, because they can add any flag. The server applies the same rules
(POST /control/project/run answers 409 without confirmed: true), so the
page cannot skip them.
The command is typed into the project’s terminal, the one docked on the project page, where its output is. On a session page the form also has Harness text for this session: Tell this session sends it to that run’s Claude, as the dispatch box does. Commands from a session page run in the project’s terminal, never in the session’s own tmux, so a command is never typed into a Claude prompt.
The form remembers the last command chosen, per project, in this browser.
tests/test-control-dispatch-form-specs.js walks every row of the routing
table and fails if the form cannot build a command, any one of its flags, or
its dry run.
The command bar: find a command by what it is for
Section titled “The command bar: find a command by what it is for”Every Control screen has Find a command at the end of its navigation.
Click it, or press Cmd-K (Ctrl-K off a Mac). Type what you want to do: the
list narrows to the routed commands whose name, tagline or summary holds every
word you typed. A word in the command’s name counts most, then its tagline,
then its summary. ship finds bob warp finalize (“ship the integration
branch to main”). health finds bob status, bob dev health and
bob forge health. Each result shows the command and its tagline.
Choose a result with Enter or a click. It opens the same form an action’s Fill in… opens: a field for each argument the command’s usage requires first, every flag with its description, and a live preview of the command that will run.
| Where you are | Where it runs |
|---|---|
| A project’s page | That project’s terminal. |
| Now, Projects, Fleet & costs, a session or an ask | The form asks first. Pick a project checked out on this machine, or This machine (no project) for a command that needs none (bob doctor, bob asks, bob update). A command that needs no project runs in the terminal of the checkout the dashboard server was started in. |
Running it follows the action rules. A command that changes
something shows the full command and runs only after Run it. A command
that only reads runs at once. Every run is a line of
$BOB_HOME/control-runs.jsonl with the action cmd:<noun> [<verb>].
The bar works from the keyboard alone. The arrow keys move through the results, Enter chooses, and Escape closes the bar. Focus goes back to what had it before the bar opened.
The list is the routing table. GET /control/commands.json
(bob-control-commands/1) reads scripts/bob/commands.json each time it is
asked, so a command added there is in the bar with no page change. Each row
becomes one spec: its command with a <NAME> per required argument, its
flags, whether it has --dry-run, whether it changes something, and whether
it needs a project. The resolver and the search are
scripts/dashboard/control-commands.js, used by both the server and the page.
Two facts are not in the routing table, so one small file declares them,
scripts/dashboard/control-commands.json:
| List | Means | A command not listed |
|---|---|---|
reads |
The command only reads, so it runs without asking. | Changes something, and asks first. |
machine |
The command needs no project, so it can run on this machine. | Needs a project. |
make check-control-actions fails when either list names a command that is
not in the routing table.
A bar run sends POST /control/project/run with command_id in place of
action (both at once is 400; an unknown command is 404). A run of a
machine command may leave project out, and its log line has
project: null and where: "machine". Any other run without a project is 400.
tests/test-control-command-bar.sh drives the bar in headless Chrome against
the real server and a real tmux terminal: Ctrl-K, the searches above, the
arrow keys, the form and its preview, a confirmed run on a project page, the
picker on Now, a run on this machine, and Escape returning focus.
tests/test-control-actions.sh adds a fixture row to a copy of the routing
table and finds it, and checks the run rules for command_id.
Never used, new and not tried
Section titled “Never used, new and not tried”Control marks the commands the operator has not used yet, so what BoB can do shows up where he works.
| Mark | Where | Means |
|---|---|---|
never used |
A command bar result, an action row | No session ledger on any fleet machine holds a run of the command. |
new |
A command bar result, an action row | The command first shipped in a release newer than the one this browser last opened Control on. |
<N> things here you have not tried |
The Work tab, under the actions | bob tour lists N items provisioned in this project and never invoked here. |
never used rests on the evidence bob tour --fleet grades from: the
fleet usage that scripts/evidence/fleet-usage.js joins from every machine’s
published snapshot. The session ledger
records each routed bob command a session’s Bash call runs, as a cli
event (bob warp status), and each command a Control run types into a
terminal on this machine (source control). Hover the tag to see which
machines reported and which did not. When the fleet usage cannot be read, no
command is tagged. A missing tag is never a claim that a command was used.
Ledgers started before cli events existed hold none. The fleet snapshot
fills each one once per machine from the session transcripts
(backfill-transcripts.js --cli-once, remembered in
$BOB_HOME/.ledger-cli-backfill.json). That fill skips any call the live
hook already recorded.
new compares the release a command first shipped in
(scripts/bob/since.js, the same value as the CLI reference’s Since) with
the release this browser last opened Control on. That release is a
per-browser convenience, kept in localStorage and held for the visit in
sessionStorage, so a new release’s tags stay up while you look around. On a
browser that has never opened Control, nothing is new. A command in no
release yet counts as newer than every release.
GET /control/commands.json carries all of it: never_used on each command
(null when the usage cannot be read), since, this machine’s release from
$BOB_HOME/.bob-version, and evidence, the line the tag shows on hover. The
rules are in scripts/dashboard/control-feature-tags.js, used by both the
server and the page.
Not tried is a folded line. It opens to the project’s bob tour items
graded unknown. Each row has the tour’s line on when to reach for the item,
and a button:
| Item | Row | Button |
|---|---|---|
A bob command |
The command bar’s form for it | As in the bar |
| A skill or a slash command | claude /<name> |
Run starts a Claude session on it in the project’s terminal |
| An agent | claude <REQUEST>, prefilled “Use the name agent to “ |
Fill in… |
The tour is read for the checkout on this machine and kept for a minute. A
project that is not checked out here has no line. A row’s run sends
command_id tour:<item>. The server looks that id up in the project’s own
tour, never in the request, and refuses one the tour does not list (404).
tests/test-control-feature-tags.sh checks all three against the real server
and headless Chrome. Its fixture ledger holds one bob status run. The
expected results:
- An action whose command was never run carries
never used, and thebob statusrow does not. - Nothing is
newuntil a release is recorded, then a later command is. - When the usage cannot be read, nothing is tagged.
- The folded line’s count matches
bob tour. - Run on a skill types
claude /<name>into a real terminal.
tests/test-ledger-bob-commands.sh covers the ledger side:
- Each bob command of a compound line is recorded.
- Quoted text and heredoc bodies are not.
- The backfill and the once-per-machine fill work without double-counting.
The terminal beside it
Section titled “The terminal beside it”The right column is the project’s own terminal. When the project is checked out on this machine, the terminal opens with the page, in that checkout. The host picker above it offers every machine that has the project; pick another and press Open terminal to open one there.
When no terminal can be opened the page still renders, and the section says why: the project is not checked out on this machine, no fleet machine has it, or the viewer may not have a terminal.
tests/test-control-project-page.sh starts the real server on a scratch
project and reads the page: no portfolio grid, one navigation bar, the summary
line, the two columns, every action, the terminal section and each tab.
Running: what is running, and where
Section titled “Running: what is running, and where”The project’s page has a Running section above its tabs: one row per live tmux session of the project, on every fleet machine, whether a BoB run is in it or not (#4027). A tmux session counts when it was opened in the project’s checkout or in one of its linked worktrees. A run that is in no tmux session has a row of its own.
Clicking a row puts that tmux session in the page’s docked
terminal and highlights the row. The page does not
reload; its address changes to ?project=<p>&tmux=<name> (with &host= for
another machine, &checkout= for a worktree), so a reload opens the same
session. Each row has an Open button beside it, which goes to the
session’s own page: ?project=<p>&session=<tmux name>. Beside Open, a
row on this machine has Kill now, and a run that declares a graceful stop
also has Stop (#4028). See Stop and Kill now.
The section is a collapsible list, open by default. Its heading, Running, counts the rows; clicking the heading folds the list away and opens it again. Folding it leaves the terminal attached. The browser remembers whether the list was folded or open (in its local storage), so a reload keeps it as it was.
The row whose tmux session the page’s terminal is
attached to is highlighted: it has a coloured bar on its left edge and says
in the terminal beside its kind. Screen readers hear it as the current row
(aria-current="true"). The highlight uses the system highlight colour, so it
shows in light and dark themes alike. When the page opens the project’s own
terminal, that is the term-<project> row. The highlight follows the
terminal: when it attaches to another tmux session it moves to that row, when
the terminal is closed no row is highlighted, and the ten-second refresh keeps
it. The terminal section carries what it is attached to as
data-attached-tmux and data-attached-host, and tells the page with a
control-terminal-attached event.
tests/test-control-session-highlight-page.js checks all of this in headless
Chrome against the real server and a throwaway tmux server.
| Column | Meaning |
|---|---|
| tmux | The tmux session’s name, with in <worktree> for a linked worktree. A run in no tmux session says no tmux session. |
| host | The fleet machine’s id. |
| kind | The run in the session. A state machine is shown by its name: a warp-drive run is “warp”, beside decompose and do. A warp run in a linked worktree is fork. A groom, plan or ritual run is listed under its own name. A tmux session with no run in it is a terminal. |
| phase | The run’s current phase. A terminal has none. |
| target | The issue, area or scope the run works on. |
| running | How long the run has run, or the tmux session when there is no run in it. |
| stuck | stuck: quiet for 2h when the run has had no activity for longer than an hour, else not stuck. This is the same rule and the same hour as Running now. A run whose activity was not measured says stuck not measured. A terminal says no run. |
The rows come from GET /control/project.json?project=<name>
(bob-control-project/1: a sessions array, a machines array, a
terminal_hosts array and a worktrees array, #4041). Each sessions element
carries tmux, kind, phase, started_at, stuck and quiet_s, plus two
addresses. here is where a click goes: the project page with tmux=<name>,
or null when the row has no tmux session. screen is where Open goes. A
row in a linked worktree carries checkout, the worktree’s name, and both its
addresses carry &checkout=<name>. Three fields drive the row’s actions
(#4028): run_kind is the run’s own kind (warp-drive for a row whose
kind column says warp), stop is the graceful stop its operations.json row declares (null
for a bare session or a run with none), and killable says whether Kill
now is offered. Another machine’s rows have no stop and are not
killable. The page reads the document again every ten seconds.
The local activity behind the stuck verdict is the run’s state file: its
updated_at, else when the file was last written. On another machine it is
the feed’s last activity for a warp run. Nothing the feed carries says when
any other run last moved, so those runs say stuck not measured.
This machine is read live: its state files, its linked worktrees and its tmux sessions. Every other machine is read from the fleet feed, so its rows are as old as its last published snapshot. A machine whose snapshot is older than the feed’s freshness window (24 hours by default) is named under the rows as stale, with its last-seen time, and each of its rows says last known. A stale row is never shown as live.
Another machine’s rows come from its fleet snapshot. For each checkout, the
snapshot lists every live tmux session open in it, with when it started
(worktrees[].tmux). scripts/fleet/project-live.js reads that list with
tmux list-sessions, on the tmux server WARP_TMUX_SOCKET and TMUX_BIN name.
A warp run’s own session is worktrees[].warp.tmux. So another machine’s rows
are:
- its warp run, with its tmux name;
- a warp run in one of its worktrees, as
fork; - each other tmux session there, as a
terminal; - its other runs (decompose, do, groom and the rest), with no tmux name.
The snapshot does not say which tmux session holds a run like decompose, so
that run’s session also shows up as a terminal row.
When tmux could not be read there, the snapshot says why (tmux: null and
tmux_reason), and the rest of the snapshot is kept. A snapshot from a BoB
before #4027 has no tmux list at all. Either way, that machine’s runs are
listed and its bare sessions are not. The list says so under the rows, with
the reason, once per machine (terminals_listed: false and
terminals_reason in the document).
Open goes to the session page by the tmux session’s name (#4026). A run
in no tmux session goes by its kind, and a groom, plan or ritual run in no
tmux session has no Open. A row on another machine opens that machine’s own
Control when its dashboard address is known. When it is not, it opens this
server’s session page with host=<machine>, whose
terminal reaches that machine over Tailscale SSH.
Clicking a row on another machine puts that machine’s session in the docked
terminal, over Tailscale SSH, when the project is checked out there. A
modified click (to open a new tab), or a row with no tmux session, follows the
row’s link instead.
tests/test-control-tmux-rows-page.js checks this in headless Chrome against
the real server and a throwaway tmux server. It sets up two sessions: one with
a decompose run in it, quiet for two hours, and one bare. It checks that each
row shows its name, host, kind, phase, age and stuck verdict. It checks that a
click switches the terminal and the highlight without reloading the page, and
that Open goes to the session page.
The page’s Terminal section has a host picker and an Open terminal button. The button opens the project’s own terminal on the picked host, in the page, and becomes Close terminal while it is open. Opening it again later attaches to the same tmux session.
The picker offers the document’s terminal_hosts: exactly the machines that
have the project checked out. That is this machine when the project is here,
and every other machine whose snapshot lists it as present. This machine is
first and selected. A machine past the freshness window is offered with its
last-seen time; if it cannot be reached, the terminal says so. A machine
without the project is not in the list. When no machine has it, the section
says so and has no button.
tests/test-control-project.sh starts the real server beside a fresh and a
stale snapshot and reads the document, the rendered rows and the Terminal
section.
New session: starting one
Section titled “New session: starting one”The project page has a New session button above its Running list (#4029).
It opens a form, and Start starts the session and opens its
session page at
/control?project=<p>&session=<name>, with its terminal attached. A session on
another machine adds &host=<machine>; one in a linked worktree adds
&checkout=<worktree>.
| Field | What it offers |
|---|---|
| Kind | Every row of scripts/dashboard/operations.json with a kind (terminal, claude, warp-drive, decompose, do, groom, plan, ritual), then every machine scripts/lib/session-machines.json declares. A machine with no row starts with the table’s new_session.machine_default, so a machine added to the registry is offered with no page change. |
| Host | The machines the project is on, this one first. A start on another machine is sent to that machine’s dashboard server, as every dispatch is (cross-machine dispatch). |
| Model | For a kind that runs in a harness (Claude, warp-drive, decompose, do): the default harness’s models, or its own default. |
| Input | What the kind needs, from its row’s target: the question for do, the issue for decompose, an optional issue, area or flight plan for warp-drive. Hidden for a kind that takes nothing. |
| Start in | The main checkout, a new worktree, or one of the project’s existing worktrees on that host. A new worktree is the default while a warp run holds the main checkout. It is made under <project>.worktrees/<kind>-<n> on a branch session/<kind>-<n>, from origin’s default branch, fetched first. |
The session’s tmux name is the one declared pattern, new_session.name in
operations.json: <kind>-<project>-<n>, where <n> is the first number no
tmux session on that host has, for example do-bigbrain-2. It reads the same
in the Running list and in plain tmux ls. A session in a worktree keeps the
project’s name, so it is listed under the project, in that worktree.
A start that would break a declared rule is refused, with its reason, before anything starts. No session is started and no worktree is made:
| Rule | Where it is declared |
|---|---|
| One run of a kind per checkout | one_per_checkout: true on the kind’s row (warp-drive, do): a second one in the same checkout is refused. |
| Kinds that may not run side by side | excludes on the row: do is never started beside a warp-drive run in one checkout, nor warp-drive beside a do run. |
| A kind the machine may not start | sessions.forbid in the machine’s global manifest (_bob-home.<machine-id>.json, then _bob-home.<role>.json, then _bob-home.json), read by scripts/fleet/role-kits.js as the machine’s kits are. |
A new worktree whose session did not start is removed again. Its branch is deleted only while it still points at the commit it was made from.
The start is a dispatch action, POST /bob/dispatch/new with
{ project, machine?, kind, input?, model?, where? } (scripts/dashboard/session-new.js,
wired in dispatch.js). So its trust rules are the dispatcher’s: a caller on
this machine with its token, or a machine machines.json declares. It starts
warp-drive through launch.sh start and every other kind through
launch.sh exec. terminal and claude start without bob preflight.
tests/test-control-session-new.js checks this against the real server and
launcher on a throwaway tmux server, with a stub claude. It covers each kind
from the registry, the name pattern, both refusals, the default to a new
worktree from an up-to-date origin, and a start in an existing worktree. Then,
in headless Chrome, it starts sessions from the button and lands on their
pages.
Stop and Kill now: ending a session
Section titled “Stop and Kill now: ending a session”A session can be ended from its row in the Running list and from its own session page (#4028). Each button asks first, and the question shows the exact command and the host it runs on. Nothing runs until Stop it, Kill it or Remove it is pressed; Cancel runs nothing.
| Button | Offered for | What it runs |
|---|---|---|
| Stop | A run whose kind declares a graceful stop in scripts/dashboard/operations.json (stop): warp-drive, decompose and do. |
The declared stop (bob warp stop, bob decompose stop, bob do stop) in the run’s checkout, through POST /bob/dispatch/stop. The run ends at its next safe point, as Stop it on the session page does. A run in a linked worktree is stopped there: the request names its checkout. |
| Kill now | Every tmux session on this machine: a run’s or a bare one. | tmux kill-session -t <name> on the session’s host. For a state-machine run, the run’s declared hard stop runs first, in its checkout (hard_stop in operations.json: bob warp stop --hard for warp-drive, bob decompose stop and bob do stop for the others). So no state file is left saying a dead session is still running. A hard stop that fails kills nothing and says why. |
After a kill, the row is gone at once (the list is read again), and the session’s page says ended, with a link back to the project. When the killed session was in a linked worktree, the outcome says what became of it:
- Merged, nothing else open in it: the outcome offers Remove the
worktree…. It asks first, showing
bob fork drop <worktree>and the host, then runs it in the project’s main checkout. The branch is kept. The ended page of a session in that worktree (&checkout=<name>in its address) makes the same offer. - Not merged (or its merge could not be measured, or another session is
still open in it, or it is not under
<project>.worktrees/): it is kept, and the outcome says why. An unmerged worktree is never removed. The server measures merged again before it removes anything, andbob fork dropitself refuses a worktree with uncommitted changes.
The rules are the action rules:
| Rule | What it means |
|---|---|
| A request names a session, never a command. | POST /control/session/kill takes { project, tmux, machine?, confirmed? }; POST /control/session/drop takes { project, checkout, machine?, confirmed? }. The commands are the server’s. A body with any other field is refused, HTTP 400. |
| It asks first. | Without confirmed: true nothing runs: the answer is HTTP 409 with needs: "confirmation", the host and the exact commands. That is what the page’s question shows. |
| Only a session of the project, on this machine. | A tmux session not open in one of the project’s checkouts is 404 (ended: true). A session on another machine is refused (409): kill it from that machine’s own Control. |
| Never the session serving Control. | The tmux session this dashboard server runs in is refused (409, serving: true), with the reason: killing it would end the server serving the page and its terminal. |
| Who may, from where. | As for an action: a caller on this machine with its token, or a machine machines.json declares, arriving on this machine’s loopback or tailnet address. |
| Every kill is logged. | One line in $BOB_HOME/control-runs.jsonl: the project, action: "kill", the tmux session, the command, the hard stop, the host, the time and who asked. A removed worktree is action: "drop-worktree" with its checkout, branch and command. bob control runs prints both. A refused request adds none. |
The routes are scripts/dashboard/control-session-kill.js; the buttons and
their questions are mountSessionActions in scripts/dashboard/control-project.js,
which the session page also uses.
tests/test-control-session-kill-page.js checks this in headless Chrome
against the real server, run inside a tmux session on a throwaway tmux server,
with the real bob warp stop --hard and bob fork drop. It kills a bare
session from its row (the question shows the command and the host; the row
goes) and another from its page (the page then says ended, with the link
back). It kills a warp run (the hard stop runs first and its state file is
gone), and is refused the session the server runs in, a session on another
machine and a caller with no token. It kills the session in a merged worktree
and removes the worktree from the offer, and kills the one in an unmerged
worktree, which is kept and whose removal is refused. Finally it reads the
run log and bob control runs.
Dispatch: who receives an instruction
Section titled “Dispatch: who receives an instruction”An instruction typed while looking at one session must not start another. So the dispatch box does a different thing on each screen.
| Screen | The box is labelled | What submitting does |
|---|---|---|
| Session | Dispatch to the <kind> session, for example “Dispatch to the decompose session” |
Types the text into that session’s terminal and presses Enter. No other session receives it. Nothing is started. |
A project’s card on bob viz (/bob) |
Dispatch | Resolves the instruction to an operation, shows the command it would run, and starts it on Run, as a new session. The model picked in the box’s model picker is passed to it. |
The project’s own page has no dispatch box: work is started there from its
actions. The card’s model picker lists the models the selected harness
declares, after Default model. A picked model appears in the previewed
command (for Claude Code, --model opus), and Run starts the operation
with exactly that model. With Default model, none is passed and the harness
runs its own.
On a session screen the box has no harness selector, no model picker and no
Run button. The
reply says which session received the text, for example Sent to the decompose session (bob-bigbrain-decompose-issue-7) on <host>.
The server takes a session’s text as POST /bob/dispatch/tell with the
session’s kind and one line of text. It types the line into the tmux session
that hosts the project’s running session of that kind.
A session that has ended is refused. The message names the session, for
example the decompose session in bigbrain has ended (its run finished); nothing was sent, and nothing is written to any session. Ended means there is
no running session of that kind, its process is gone, or its tmux session is
gone.
The endpoint is described in the
bob viz reference.
tests/test-control-dispatch.sh starts the real server beside two sessions on
a throwaway tmux server, submits text through the session screen’s own chat
module, and reads what each session received. It then starts an operation
from a project card’s box with a model picked, and reads the arguments the
new session’s harness received.
The Session page: one run
Section titled “The Session page: one run”/control?project=<name>&session=<tmux name> is one running state machine
(#3929), addressed by the tmux session it runs in (#4026).
Watching a run is the operator’s second job, so the page brings together what
bob warp viz and the project page show in separate places. Top to bottom:
| Part | What it shows |
|---|---|
| Heading | The kind of run (warp-drive, decompose, do), then what it is working on, in plain words. For a warp run it is Working on #N: <title>; for decompose, Breaking #N into requirements: <title>; for do, its question. |
| Line | The plan position (Item 2 of 3 on flight plan #40), the chunk, healthy or stuck (quiet for more than the stuck threshold), and the last activity. A part the run does not have is left out. |
| State row | The machine’s states in order, as pills joined by arrows. States before the current one are outlined in green, the current one is filled, and the rest are muted. The row ends with ↺ next item where the machine loops. Click it, or press Enter on it, to open every state below it: the path taken, and how often and how long the run was in each. Click again to close. |
| Status row | The phase and how long it has been in it, plan items done of total, acceptance criteria met of total, and the run’s estimated cost and segment count. A figure the machine type does not report is left out, never shown as zero. A machine adds its own figures: decompose the capability it breaks down and how many requirements are drafted and filed; do the time budget used (12 of 60 min) and the artifact so far. |
| Tabs | Acceptance criteria (open first): the current item’s checklist, met and open. Feed: the run’s transitions, newest first. Plan: the flight plan’s items as done, running, next or gated, with what a gated item waits on. Commits: this run’s commits. Files: the files those commits changed. A machine’s own lists come first: decompose’s Requirements, each drafted, filed (with its number) or dropped. A tab with nothing to show says so in one line. |
| Steer this run | Tell it something: the dispatch box, which reaches this session only. Stop it: shows the command it runs (bob warp stop, bob decompose stop or bob do stop, as scripts/dashboard/operations.json declares) and runs it only after Run it, as a project page action that changes something does. A run in a linked worktree is stopped there. |
| End this session | Kill now… (#4028): asks first, showing the host and the exact commands (the run’s hard stop, then tmux kill-session -t <name>), then kills the session and the page says ended. See Stop and Kill now. A session on another machine says to kill it from that machine’s own Control. A terminal page (a tmux session with no run) has the same section under its terminal. |
| Terminal | The right column: the session’s terminal, open with the page, and kept in view while the left column scrolls from 1100 px wide. |
Every machine gets the same page. The state row and the status come from
the machine’s own definition and state file: its graph (states, transitions,
phase_meta) and its history. A machine added to the shared engine (#3815) gets
its page with no code of its own in Control. A state its phase_meta marks
step: false (a side or end state: warp’s blocked, aborted, completed,
decompose’s stopped) is left off the row, but it is still listed when the row
is opened. “Next item” is shown when the row’s last state has a transition back
to an earlier one.
A machine’s own figures (#4031) come from an optional screen(data, { phase, history, now }) on its definition (scripts/warp-drive/machine-engine.js):
a heading, figures for the status row and lists that become tabs. Control
draws them without knowing the machine.
Why a decompose run used to vanish (#4031): the engine recorded the pid of
the shell that ran bob decompose, which a Claude Bash call exits at once. Five
quiet minutes later the run read stale, and Control could not find its tmux
session by pid. Every machine now records the Claude session it belongs to,
the nearest claude ancestor.
The page is built by scripts/dashboard/control-session.js (bob-control-session/1
for the document). A warp run’s data comes from the engine’s graph and status
(token_usage.estimated_usd is the cost), its issue’s checklist and its flight
plan. Another machine’s comes from its definition and its state file. This
run’s commits are git log --since the run’s start; its files are the diff from
the HEAD it started on.
On the project page, each live run in the Running section shows the same states as a segmented bar 8 px tall, with no labels. Its Open button, beside the row, goes to this page.
tests/test-control-session.sh starts the real server for a scratch project
with a fixture warp run, a stub gh and flight plan, and a fixture machine
declared through $BOB_SESSION_MACHINES. It checks:
- the heading and its line;
- the state row’s steps and current state, and the opened graph;
- the status figures and the five tabs;
- the docked terminal;
- the fixture machine’s row;
- the project page’s bar;
- Stop it: refused without its confirmation, and with it a real
bob warp stopthat leaves the runaborted.
tests/test-control-session-address.sh starts the real server beside a
private tmux server holding a warp run’s two segment sessions, a decompose run
and a bare shell. It checks that each run’s tmux name opens its own page with
its kind in the heading, that the shell’s opens a terminal page whose
terminal attaches by name, that the old segment’s name redirects to the run’s
session before and after it is gone, that session=warp-drive and
session=decompose redirect and session=do says none is running, that a
name that is not open renders ended, and that the project’s Running rows link
by tmux name.
tests/test-control-decompose-session.sh (#4031) plays a Claude session that
runs bob decompose 7 on a fixture capability through a short-lived shell, and
a bob do run in a linked worktree. It back-dates both state files ten minutes
and checks, through the real server: the run is still live; its tmux session’s
page shows the six phases, the capability, the drafted and filed counts, the
Requirements tab and the terminal; and Running now lists both runs in plain
words.
The session terminal
Section titled “The session terminal”A session screen carries a Terminal pane on the tmux session it is
addressed by, opened by name (#4026). On the Session page it is open, so it attaches as the page loads. Once open, what the
session prints appears in it and what is typed in it reaches that session. It
is the same tmux session a tmux attach in a shell would reach. The pane’s
first line names the tmux session, its machine and its size.
The pane is drawn with xterm.js, vendored unchanged in
scripts/dashboard/vendor/xterm/ and served by the dashboard server.
vendor.json beside it records the version, where it came from, its checksum
and how to update it. The pane’s code is scripts/dashboard/control-terminal.js.
The server attaches with tmux’s control mode (tmux -C attach), a line
protocol on a pipe, so nothing is compiled and no pty is held. The attachment
sets no size, so tmux never resizes the session’s window for it: opening the
terminal does not reflow the session for anyone else attached, and the pane
is drawn at the size tmux reports. The Project page’s terminal asks for a size
that fits its column (resize, below), which is granted only while it is the
session’s one client.
| Route | What it does |
|---|---|
POST /control/terminal/open |
Takes project and session (the kind, such as warp-drive), tmux (the name of a tmux session in the checkout), or checkout: true for the project’s own terminal. Finds the tmux session as a tell does, attaches, and answers bob-terminal/1: opened, id, host, tmux, cols, rows. With no tmux session to attach to it answers 409 with opened: false, the host and the reason. |
GET /control/terminal/stream?id=<id> |
A text/event-stream. screen carries the pane as it stands (cols, rows, data in base64), first and again when its layout changes. data carries the bytes the pane wrote, in base64. ended carries the reason the attachment is over. |
POST /control/terminal/input |
Takes id and data (base64). The bytes are typed into the pane, in order. |
POST /control/terminal/resize |
Takes id, cols (20 to 500) and rows (5 to 200). Sizes the attachment with refresh-client -C only when no other client is attached to the tmux session, and answers resized: true; otherwise 409 with resized: false and the reason. tmux then reports the new layout, and the stream’s screen event carries it. The Project page sends it when its terminal’s column changes width. |
POST /control/terminal/close |
Takes id and detaches. |
Closing the pane or the page closes the stream. Closing the stream detaches, whether the pane, the page or the network closed it. Detaching ends the control client and nothing else: the tmux session keeps running.
Who may open a terminal is who may dispatch: a caller on this machine with its
token, or a machine machines.json declares. Every terminal route also
refuses, with HTTP 403, a connection that did not arrive on this machine’s
loopback or tailnet address.
A session on another machine
Section titled “A session on another machine”open also takes machine, a fleet machine’s id; a session screen opened with
host=<machine> sends it. For a machine other than the one serving the page,
the server starts the control client there over Tailscale SSH, to the
machine’s tailscale_name in machines.json, with the transport the
cross-machine audit uses. The far side runs
scripts/dashboard/terminal-attach.js, which finds the tmux session on its
own machine and attaches. Keys and output then travel the same routes.
The page’s graph and feed on such a screen are this machine’s checkout. A line at the top of the frame says so, and names the machine the terminal is on.
When there is no terminal to show, the pane says which machine and why, in place of an empty terminal:
| Case | What the pane shows |
|---|---|
| The machine does not answer SSH | <machine>: <machine> could not be reached over Tailscale SSH: <what ssh said> |
| The machine is reached, and the session is not running there | <machine>: the <kind> session in <project> has ended (<why>) |
| The session runs outside tmux | <machine>: the <kind> session in <project> is not running in a tmux session on <hostname> |
The machine is not declared with a tailscale_name |
<machine>: <machine> is not a machine machines.json declares with a tailscale_name, so there is no way to reach it |
A project’s own terminal
Section titled “A project’s own terminal”open with checkout: true in place of session opens the project’s own
terminal: a shell in the project’s checkout. It lives in one tmux session per
project per machine, named term-<project>. The first open starts it, with
its working directory at the checkout; every later open attaches to the same
one, so a command left running there is still there. The reply carries
checkout: true and started, which is true only for the open that started
it. With machine, the session is that machine’s, in that machine’s checkout.
Closing detaches as it does for a session’s terminal. The tmux session stays
until it is ended from inside (exit) or with tmux kill-session.
open with host: true and no project opens the machine’s own terminal
instead (#4032): the tmux session bob-host, a shell in the home directory,
started on first open. No project’s terminal can have that name, because
theirs all start term-. The reply carries host_terminal: true. Control’s
New project form runs bob ready in it.
A tmux session by name
Section titled “A tmux session by name”open with tmux: <name> attaches to that tmux session, which must be open in
one of the project’s checkouts (the checkout itself or a linked worktree) on the
machine. It never starts one. A session of that name somewhere else answers 409
with the reason, and a name that cannot be a tmux session name answers 422. The
reply carries named: true. With machine, it is that machine’s session,
reached over Tailscale SSH. The project’s page uses it for its Running rows.
tests/test-control-terminal.sh starts the real server beside a tmux session
running a shell, types echo control-ok through the terminal, and reads
control-ok back with tmux capture-pane. It drives the page’s own terminal
module against the server, attaches to a second machine through a stub ssh,
and reads the pane’s words for a machine that cannot be reached.
The asks document
Section titled “The asks document”The Now page lists every project’s asks, and a project’s page lists its own, in the same row form. Both are drawn from one document.
When the asks cannot be read, the page says so and gives the reason, for
example Asks unavailable: no project could be read (2 tried): forge rate-limited — API rate limit exceeded. The rest of the page is unaffected.
The rows are in the page as served when bob asks answers within four
seconds. When it takes longer the page says Reading the asks… and fills
itself. Either way the page reads the document again every minute.
The document is GET /control/asks.json
(bob-control-asks/1). The server gets it by running bob asks --json
(bob-asks/1), at most once a minute, because each
read costs two forge calls per project.
| Key | Value |
|---|---|
asks[] |
The rows of bob-asks/1, ordered by priority (p1-critical first, no priority last), then oldest first. Each has project, kind, minutes, place, priority, priority_rank, title, number, created_at, age_days, url, unblocks and order. |
asks[].order |
The row’s place in bob-asks/1, which lists the most-blocking ask first. The Now page lists its rows in this order. |
count |
The number of rows. |
held[] |
The asks bob-asks/1 holds behind the attention budget, in the same shape. Their order follows the listed rows’. |
waiting |
How many asks are held back behind the attention budget: the number of held rows. Zero when there are none. |
available |
false when bob-asks/1 could not be had. reason then says why, for example no project could be read (2 tried): forge rate-limited — API rate limit exceeded, and asks is empty. |
gaps[] |
Projects the forge did not answer for, when others were read. |
?project=<name> returns only the rows whose project is that name.
minutes and place come from the Minutes: and Place: lines of the ask’s
body. An ask that states neither has null for both.
tests/test-control-asks.sh starts the real server against two scratch
projects and a stub gh, and reads the document and a project’s page.
The Ask screen: one ask and its steps
Section titled “The Ask screen: one ask and its steps”Clicking an ask’s row opens /control?ask=<owner/repo#N>. The screen opens
with a Back to Now link, then the ask’s title without its TODO: prefix.
Under the title are its project, its minutes when the body states them, its
place, and what it unblocks: the unblocks.summary that
bob asks gives the ask (#3915). That line is left out
when the asks document has not answered within the Now page’s wait. The
opening paragraph follows.
The rest of the body is rendered as markdown: headings, lists, links, code
and #N as a link to that issue. Only http and https addresses become
links, and nothing in the body is passed through as HTML. The Kind:,
Minutes:, Place: and Tried: lines and the ## Verify section are
left out, because the header and the finish card show them.
Each numbered step is a card. The card shows the step’s words and one button, chosen by what the step holds:
| The step holds | Its button |
|---|---|
| A fenced command | The command in a block with Copy, and Run under the Run button’s rules. An ask that did not pass the gate has Copy only. |
A link (a markdown link or a bare https address) |
Open, which opens the first link in a new tab. |
| Anything else | Did it, which marks the card done on this page and nowhere else. Pressing it again undoes it. |
A step written in three parts also shows its “You should see” sentence and the sentence after it that says what broken looks like (“If you see an error instead, …”). A part the step does not state is left off the card. Nothing is filled in for it.
An ask written before the three-part form is rendered the same way. Its numbered list becomes cards, mostly Did it ones, and its other paragraphs are markdown. The screen shows no notice about what it lacks.
The last card is the finish: Check when the ask states a Verify command that can be run (Check), else Done (Done and Park). Below it are two secondary links: Park and Read the original issue.
The terminal pane is drawn only when at least one step is a command and there
is a machine to run it on. It is the project’s own terminal
on that machine: the tmux session term-<project> there, started in that
machine’s checkout the first time and reattached after. A copied command is
pasted where it has to run. The project is the registered one named as the
ask’s repo is.
Otherwise the steps take the full width and there is no pane:
| The ask | What the screen shows in place of a terminal |
|---|---|
| Has no command | Nothing. |
Says Place: phone |
Nothing. |
| Names a place the fleet does not have, or its repo is not a registered project | One line above the steps: <place>: <place> is not a machine in the fleet (<the machines>), so it cannot be reached, or the project reason. |
| Has a command and names no place | Open a terminal on, a list of the fleet’s machines. The machine picked (&host=<machine>) gets the terminal. Run stays off, because an ask with no place did not pass the gate. |
A fleet machine that does not answer SSH still gets the pane. The pane says
<machine> could not be reached over Tailscale SSH: <what ssh said> when the
terminal tries to open.
The Run button
Section titled “The Run button”Each step of an ask that passed the admission gate
has a Run button beside Copy. Pressing it types that step’s command into
the ask’s terminal and submits it, so the output appears in the terminal on
the screen. A step of several lines is submitted line by line. Under the step
the screen says Sent to the terminal on <machine>, or Not run: and the
reason.
The button runs a command from a web page, so the route has rules:
| Rule | What the server does |
|---|---|
| A request says which step, never what to run. | POST /control/ask/run takes { "ask": "<owner/repo#N>", "step": <n> }. The server reads the command from the ask’s issue. A request that carries any other field, such as command text, gets HTTP 400 and nothing is typed. |
| Only an admitted ask is run. | An ask whose body does not state all five admission fields (Kind:, Minutes:, Place:, numbered steps, Tried:) has no Run button. A notice above its steps says which field it does not state. The route answers HTTP 403 for it. |
| The command runs on the machine the ask names. | It is typed into term-<project> on the machine in the ask’s Place: line, over Tailscale SSH when that is another machine. A request may name the machine it expects in machine. When that is not the ask’s machine the route answers HTTP 409 and nothing is typed. |
| Who may run a step is who may open a terminal. | A caller on this machine sends the page’s token. A caller on another machine must be one machines.json declares, as tailscaled identifies it. Anyone else gets HTTP 403. |
| The request arrives on loopback or the tailnet. | A connection that arrived on any other address of this machine gets HTTP 403, whatever address the server was told to bind. |
| Every run is logged. | One line is added to $BOB_HOME/control-runs.jsonl on the machine whose server typed the step. A refused request adds none. |
A step number the ask does not have gets HTTP 404. A step with nothing to paste gets HTTP 422. When the ask’s terminal cannot be opened, the answer is HTTP 409 with the machine and the reason.
The answer is bob-control-run/1: ran, ask, step, host, tmux (the
tmux session typed into) and logged, or ran: false and error.
bob control runs prints the log, one line per run, oldest first:
2026-10-04T02:41:07.512Z acme/site#41 step 1 on bench-a by bench-a2026-10-04T02:44:30.090Z site: bob warp finalize --dry-run on bench-a by bench-a2026-10-05T09:12:44.301Z site: bob warp stop --hard, then tmux kill-session -t warp-seg-site-2 on bench-a by bench-a3 runs (~/.claude/control-runs.jsonl)bob control runs --json prints one bob-control-runs/1 document:
| Field | Meaning |
|---|---|
log |
The log’s file. |
count |
How many runs it holds. |
unreadable |
Lines of the file that are not a run. They are counted and not shown. |
runs[] |
at (the time, ISO 8601), ask, step, host and caller. A Verify has step: "verify" and exit. A project page’s action has project, action, command and dry_run in place of ask and step. A kill has action: "kill", tmux, command, hard_stop (null when none ran) and, in a worktree, checkout; a removed worktree has action: "drop-worktree", checkout, branch and command. |
runs[].caller |
Who asked. For a caller on the server’s own machine: identity is that machine’s id and local is true. For a caller on another machine: identity is its tailnet node name as tailscaled gives it, machine is its machines.json id and local is false. address is the address the request came from. |
A log that does not exist yet is zero runs, exit 0.
Check: the ask closes itself
Section titled “Check: the ask closes itself”Nobody reports back what they saw. An ask says how it is checked, and the check decides.
The Verify command is read from the ask’s body: the first fenced block under
its ## Verify heading, or a Verify: line that is one code span. When the
ask states one, passed the admission gate and has a terminal, the finish card
shows the command with a Check button. Check is the #3822 Verify control
under a plainer name: the route, the rules and the answer are unchanged.
Pressing it runs the command on the ask’s machine, in the project’s checkout there, and shows what it wrote. Its exit code decides:
| The command | What happens |
|---|---|
| Exits 0 | The ask is marked done on its issue: completed is added to a to-do; a brief is set to brief:done and its other state label comes off. The screen says Verified, names the label, and links back to the asks. Opened again, the screen says This ask is done. |
| Exits non-zero, or runs past two minutes | Nothing is labelled. The screen says Not verified, with the exit code and the output. The ask stays open and Check can be pressed again. |
| Cannot be run (the machine does not answer SSH, the project is not registered there) | Nothing is labelled. The screen says Not run and the reason. |
The route is POST /control/ask/verify with { "ask": "<owner/repo#N>" }. It
follows the Run button’s rules: the command is read from the issue and a
request that carries any other field gets HTTP 400; an ask that did not pass
the gate gets HTTP 403; it runs on the ask’s machine only; the caller and the
arrival address are checked the same way. An ask that is already done gets
HTTP 409, and one that states no Verify command gets HTTP 422.
Unlike a step, the Verify command is not typed into the terminal. It runs as its own process so that its exit code can be read. On another machine the server starts it over Tailscale SSH. Its output is kept up to 64 KB.
The answer is bob-control-run/1: ran, verified, exit, output,
truncated, timed_out, host and marked (the label that was added, or
null). Each Verify is also a line of the run log, with
step set to verify and its exit.
Done and Park: an ask nothing checks
Section titled “Done and Park: an ask nothing checks”An ask that cannot verify itself ends on a Done card in place of Check. That is an ask with no Verify command, one that did not pass the admission gate, or one whose machine has no terminal. The screen says which.
| Control | What it does |
|---|---|
| Done | Opens a confirmation: Nothing was checked, why, and what Done will record. Only the confirmation’s own button, Mark done, unchecked, marks the ask done: completed on a to-do, brief:done on a brief. Cancel closes the confirmation. |
| Park | A secondary link below the finish card, on every open ask. Sets a brief to brief:parked. Closes a to-do as not planned, without completed, with a comment saying where it was parked. The work that to-do blocks stays blocked until it is reopened. |
The route is POST /control/ask/settle with
{ "ask": "<owner/repo#N>", "as": "done" | "park" }. Done must also carry
"unchecked": true, which the confirmation’s button sends. Without it the
route answers HTTP 409 and adds no label. An ask whose Verify command can be
run is refused Done with HTTP 409: its own check decides. Park is taken for
it (#3924). An ask that is already done is refused both. Any other field gets HTTP 400. The caller and the arrival
address are checked as for Run. The answer has settled, as, marked
(completed, brief:done, brief:parked or closed) and unchecked.
A finished ask leaves the page
Section titled “A finished ask leaves the page”When Verify passes, or Done or Park is pressed, the server drops what it kept
of that ask and of the asks document. The next read runs
bob asks again, which never lists a to-do carrying completed, a closed
issue, or a brief in brief:done or brief:parked. The ask is gone from the
project’s page and from the Now page within one refresh.
On a phone
Section titled “On a phone”The Ask screen is built for the phone a push arrives on, held in one hand. Below 60rem of width the terminal moves under the steps, so the screen is one column and scrolls only down. A long command scrolls inside its own block and the terminal inside its own frame; the page itself never scrolls sideways.
Below 40rem every control (Copy, Run, Open, Did it, Check, Done, Park, and the two in the Done confirmation) is at least 44 pixels wide and 44 pixels tall. A step’s Copy and Run sit under its command, and Run takes the rest of the row. Open, Did it, Check and Done fill their card’s width.
The Now page works at the same width (#3925). Each ask row puts its words above a full-width Do it at least 44 pixels tall, and a Running now row puts its words above a full-width Open. The page does not scroll sideways. The command bar becomes one 44-pixel icon button, and what it opens fills the screen. The project page, Fleet and Costs are laptop pages and are not laid out for a phone; their navigation still fits on the screen.
tests/lib/control-phone.mjs, run by tests/test-control-ask.sh, loads Now,
two Ask pages and a project page at 390×844 in headless Chrome and measures
them: the scroll width, each row’s words against its button, each card’s
width against the column, the 44-pixel buttons, the open command bar’s box
and the navigation’s links.
A handoff push
links this screen, not the issue. The link is the screen’s address on the
tailnet: http(s)://<host>/<project>/control?ask=<owner/repo#N> when this
machine’s bob serve route for the project is live,
else the MagicDNS name or tailnet IP of a tailnet-bound dashboard server. The
project’s own server is used when it runs, else the fleet’s. When no server
can be reached from the tailnet, the push links the issue.
Behind bob serve the screen answers under /<project>. The page names the
addresses its Run, Check, Done and Park controls post to, so the mount is
applied to them along with every other link on the page.
The screen’s document is GET /control/ask.json?ask=<owner/repo#N>
(bob-control-ask/1). The server reads the issue with gh issue view, at
most once a minute per ask.
| Field | Meaning |
|---|---|
ask, repo, number, url, title, state |
The issue. |
heading |
The title without its TODO: prefix: the screen’s heading. |
unblocks |
Always null in this document. The screen fills it from the asks document. |
project |
The registered project the repo is, or null. |
summary, kind, minutes, place |
The opening paragraph and the Kind:, Minutes: and Place: lines. null when not stated. |
steps[] |
n, what (the step’s own words), command, see, broken, link ({href, text}, the first link in its words). A part that is not stated is null. action is run, open or did: the card’s button. run is true when the step has a Run button. |
admitted |
true when the body states all five admission fields. |
gate_missing[] |
The admission fields the body does not state. |
issue_kind, labels[] |
todo or brief, and the issue’s labels. |
verify |
The ask’s Verify command, or null. |
done |
true when the ask is finished: it carries completed, brief:done or brief:parked, or its issue is closed. |
controls |
verify when the finish card is Check, settle when the ask cannot verify itself, null when it is done. |
as_written |
true for an ask written before the gate: no Place:, or no step in three parts. It is rendered like any other. |
lacks[] |
What the ask is missing, in words. The screen does not show it. |
body |
The body as written. |
terminal |
machine and project when a terminal can be tried; machine and reason when there is none to open. |
pane |
true when the screen draws the terminal pane: a step has a command and terminal has no reason. |
hosts |
The fleet’s machines to pick from, for an ask with a command and no place; else null. |
An address that is not owner/repo#N, or an issue gh cannot read, returns
the frame with the reason. The status is 404 when the reference is wrong or
the issue does not exist, and 502 when the forge did not answer.
tests/test-control-ask.sh starts the real server with a stub gh and reads
the document and the page for an ask written in three parts and one written
before the gate. It opens the terminal as the page does, for an ask at this
machine and one at a second machine (a stub ssh), and reads the pane’s words
for a machine that cannot be reached.
It presses Run as the page does and reads the command’s output from the tmux
pane, on this machine and on the second one. It sends a request that carries
command text, one that names another machine, and one for an ask that did not
pass the gate. It reads the run log through bob control runs --json, for a
local caller and for one tailscaled names, and sends a run to a LAN address.
It presses Verify before and after the step’s file exists, on both machines
and for a brief, and reads the labels the stub gh was asked to change. For
asks with no Verify command it sends Done with and without the confirmation,
parks a to-do and a brief, and reads the asks document before and after.
It renders three fixture asks: one of web steps only, one that mixes a link, a
command and a plain step, and one from before the three-part form. For each
it checks the button on every card and whether the terminal pane is there. It
checks the header’s unblocks line, the machine picker for an ask with no
place, and that Park is taken for an ask that checks itself.
It opens two asks in headless Chrome at a 390-pixel-wide viewport
(tests/lib/control-ask-phone.mjs) and measures the page: the terminal’s top
against the steps’ bottom, the document’s scroll width, and each control’s
size. It taps Run and reads what the step says. It then sends a handoff push
for a to-do from a shell, through a stub bridge, and reads the link in the
text that was sent. It requests that link by its tailnet address, through a
stand-in for tailscaled’s serve route, and repeats the phone checks there.
It leaves a screenshot of each screen when CONTROL_ASK_OUT is set.
Without a headless Chrome the browser checks are skipped.
Keyboard, themes and contrast
Section titled “Keyboard, themes and contrast”Control is used all day, so its pages work without a mouse and read in both themes (#3926). On Now, the Project page and the Ask screen:
- Tab reaches every button, link, folded line and form field in the order
they are written, and each shows a focus ring. Enter or Space
activates it. Nothing sets a positive
tabindex. - A line that folds is a real
<details>with a<summary>, or a<button>. What a closed fold holds is reached once the fold is opened. - Every control has a name: its text, an
aria-labelor a<label>. - Each page has one
h1, headings that never skip a level, anavand onemain. An ask’s own headings start ath2under its title. - Text is at least 4.5:1 against what is behind it (3:1 when large), and a control’s edge or fill is at least 3:1 against its surround. That holds in the light and the dark theme. A run that is stuck or a check that failed says so in words, not only in colour.
tests/lib/control-a11y.mjs, run by tests/test-control-ask.sh (so by
make test), checks all of this. It uses headless Chrome and adds no npm
dependency. It loads Now, a project page and four asks, in each theme, and
measures:
- the Tab walk, from the top of the page;
- the focus ring at each stop;
- the headings and landmarks;
- every control’s name;
- the contrast of every visible text and control. The colours are resolved through a canvas, and the backgrounds and opacity behind each element are composed.
Every finding is serious, and a page passes with none. Each run leaves
a11y-<page>-<theme>.json with what it found. The terminal’s own drawing
surface (xterm) is left out of the contrast and ring checks; Tab must still
reach it. Without a headless Chrome the scan is skipped.
The frame holds no state
Section titled “The frame holds no state”The Fleet, Costs and Session screens are the pages the server already
builds for /bob and /warp. The frame adds its navigation and a style block
to that page and nothing else; for Fleet and Costs it marks the regions the
page does not show hidden and names the page on <body data-control-view>,
and the bob viz script then draws no tile grid and skips those regions: the framed session screen is the /warp page
plus the navigation, with the session named on its dispatch box. The Now,
Projects and Project pages are the frame’s own, written on each request from
what bob asks, bob status, the registry, the fleet feed and the project’s
checkout say. Projects,
sessions and asks stay where they live (issues, tmux, state files, the fleet
feed). Stopping the server loses nothing. The one thing Control writes is the
run log, a record of what was run and not state any screen
reads.
The frame is scripts/dashboard/control.js: it reads the request and writes
HTML. tests/test-control-frame.sh and tests/test-control-nav.sh start the
real server on a scratch project and read each screen.
See also
Section titled “See also”- bob viz: the page the Fleet and Costs screens are drawn from.
- Warp-drive internals: the session screen’s page.
- bob CLI reference:
bob control.