Skip to content

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.

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 command

The 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.

Control is arranged around what the operator sits down to do, in the order he does it (cap #3913):

  1. 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.
  2. 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.
  3. Work on a project. The project’s page, laid out as the flow of the work: start, check, ship. Every action is a bob command 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.

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

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.

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.

Terminal window
bob control # prints the /control URL and opens it
bob control --tailnet # served on this machine's Tailscale address
bob control --json # one bob-viz-url/1 document for /control; opens nothing

bob control starts the dashboard server when none is running and reuses a fresh one, exactly as bob viz does.

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.

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 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.

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 #3928
item 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 Costs

It 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:

  1. 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.
  2. Machines. One card per machine machines.json declares.
  3. Capacity. Live sessions per machine against its declared limit, and the machine a new session would go to.
  4. 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.

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.

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.

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:

  1. This month. One row per route key machines.json declares (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 with bob route status at 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 by token-report.js, sit beside them with no budget.
  2. Where it went. Spend by project and by kind of run (warp-drive, decompose, do, groom, tick, interactive), this week against last.
  3. 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.
  4. 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_MINUTES in control-costs.js), each linked to its session page.
  5. 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.

/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.

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: false and has left projects.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.

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.json declares, 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.json does not declare;
  • the owner/repo is 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 turns ready;
  • 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.
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.

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 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.

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 the bob status row does not.
  • Nothing is new until 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 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.

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.

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.

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, and bob fork drop itself 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.

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.

/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 stop that leaves the run aborted.

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.

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.

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

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.

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 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.

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.

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-a
2026-10-04T02:44:30.090Z site: bob warp finalize --dry-run on bench-a by bench-a
2026-10-05T09:12:44.301Z site: bob warp stop --hard, then tmux kill-session -t warp-seg-site-2 on bench-a by bench-a
3 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.

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.

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.

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.

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.

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-label or a <label>.
  • Each page has one h1, headings that never skip a level, a nav and one main. An ask’s own headings start at h2 under 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 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.