A Global Hub dashboard for beads
(bd) issues, project docs, and your agent tmux sessions. Install it once per
machine, register the repos you work in, and get one web UI for triage,
docs, live tmux sessions, and scheduled prompts. Zero npm dependencies on the
server: just Node (>=22) and the bd CLI. The frontend is a no-build
Preact app with vendored dependencies — still no bundler, no build step.
- Global Hub: one long-running server, many registered projects. Switch between repos from a single dashboard instead of running one server per repo.
- Beads triage: every issue from
.beads/issues.jsonl, including dependencies — status lanes (Ready · Active · Blocked · Done), optional epic grouping, a dependency map, and omnibar search. Claim, close, reopen, re-prioritize, label, link and re-parent issues inline. - Rich issue creation: type, priority, labels, acceptance criteria, parent epic, and assignee — not just quick one-line captures.
- Comments: read and add comments on any issue through
bd comment(always read live, never stale). - Docs: browse project markdown, either auto-discovered or pinned via a
per-project
docRootslist. - tmux sessions: see every tmux session and pane on the host the hub runs on — what's running, where, and a live scrollback preview.
- Prompt scheduler: queue a literal prompt to be typed into an existing, named tmux session at a future time (see Scheduler below).
- Themes: six presets (Synergy — the default, Default, Dracula, Nord,
Gruvbox, Tokyo Night), each with light, dark, and auto (follows
prefers-color-scheme) variants. - Keyboard shortcuts: vim/Gmail-like navigation for fast, hands-on-keyboard triage (see Keyboard shortcuts).
This README covers install/CLI/config. For what the dashboard itself does —
every page and route, the hub's sections, Console 2.0's header/canvas/Detail
panel, the omnibar's full command list, end-to-end workflows (link two
issues, delegate to tmux, pour a molecule, check usage, update bd, and
more), and a "recently added" rundown of link types/graph-links/molecules/
usage analytics/bd-version surfacing/default epics — see
docs/feature-map.md.
You need:
- Node >=22 (required — the scheduler uses
node:sqlite, which landed in Node 22) - the
bdCLI on yourPATH - optionally,
tmuxon the same host, if you want the tmux/scheduler features (the hub degrades gracefully without it — see Scheduler)
No npm install step is required because bd-console has no npm
dependencies.
git clone https://github.com/formless63/bd-console.git ~/code/bd-console
cd ~/code/bd-console
node serve.mjs startnpm install -g git+https://github.com/formless63/bd-console.git
bd-console startEither path gives you the bd-console command (and bd-console-init, an
optional guided per-repo setup helper — see
Guided per-repo setup).
# 1. install (see above), then start the hub — installed once per machine
bd-console start
# 2. first run on a TTY walks you through a LAN vs VPS setup prompt (see
# Security posture below); non-interactive first runs apply safe-ish
# defaults and log what they picked
# 3. register the repos you want on the dashboard
bd-console add ~/code/my-project
bd-console add . # registers the repo at (or above) the cwd
# 4. open the dashboard
bd-console status # prints the URL, e.g. http://localhost:4180Each registered project needs a working beads database (.beads/) at its
root. If a target repo doesn't have one yet:
cd ~/code/my-project
bd init
bd export -o .beads/issues.jsonlThere is no single-repo server mode anymore — bd-console always runs as
one Global Hub, and every project it can see is something you explicitly
registered with add.
bd-console run the hub in the foreground (Ctrl-C to stop)
bd-console start (re)start the hub as a supervised background process
bd-console stop stop it, however it's currently supervised
bd-console status pid + port-reachability + supervision report
bd-console add <path> register a project with the hub (defaults to cwd,
walking up to the nearest .beads/)
bd-console remove <id> unregister a project
bd-console list list registered projects
bd-console settings interactive settings walkthrough
bd-console settings list print effective host/port/token/persist + source
bd-console settings set <key> <value>
bd-console settings unset <key>
bd-console update self-upgrade in place (see below)
bd-console update --dry-run print the detected install flavor + planned
command(s) without running them
Global flags (--port <n>, --host <h>) work on the bare run and on
start/stop/status, and take precedence over everything else (see
Configuration). A bare trailing number is accepted as a
back-compat shorthand for --port on those same commands.
start always supersedes any existing deployment rather than silently
no-oping:
- stops a previous plain-daemon process (tracked by pid file), if any
- stops an active systemd
--userunit, if any - as a final catch-all, checks whether something is still answering on
the configured port and, if it identifies itself as
bd-consolevia/api/meta, takes over the port too
It refuses to kill a process on the port that does not look like
bd-console (you'll get a "port already in use" error instead — free it
manually or pick a different --port). Once the old deployment is torn
down, start brings up a fresh one and polls /api/meta until the
replacement actually answers, then prints the dashboard URL and how it's
supervised. If the new instance never becomes ready, it prints the tail of
the log (systemd journal or the plain log file) and exits nonzero.
Reports three independent things: whether a pid is alive, whether the
configured port is actually reachable (a live /api/meta fetch, not just
"process exists"), and how the running instance is supervised —
systemd, plain (pid-file daemon), foreground (answering the port but
not tracked by either, e.g. you ran bare bd-console yourself), or none.
bd-console update detects how it's installed and upgrades in place:
- git-clone install (the package root is inside a git work tree) →
git pull --ff-only. A dirty working tree is never touched — no stash, no reset.updateaborts with instructions to commit or stash yourself, then retry. - npm-global install (anything else) →
npm install -g git+https://github.com/formless63/bd-console.git
If bd-console was running before the update, it's restarted afterward
through the same superseding start path, so the new code goes live
immediately. --dry-run prints the detected flavor and exact command(s)
without running them.
The version in package.json is the installed bd-console version. A GitHub
Actions workflow automatically increments its patch component after each push
to main. If the push already changes the version, the workflow respects that
deliberate major/minor/patch choice instead of incrementing it again.
For an intentional release bump, maintainers can run one of:
npm run version:bump -- patch
npm run version:bump -- minor
npm run version:bump -- majorThe command only updates package.json; it does not implicitly commit, tag,
publish, or push. The workflow can also be dispatched manually with the same
three choices. This keeps GitHub-installed copies distinguishable even though
bd-console is not yet published to the npm registry.
On Linux, when systemctl --user is usable, start installs/refreshes a
systemd --user unit (bd-console.service) and runs
loginctl enable-linger so the hub survives logout and reboot — this is the
default (persist: true) whenever that probe succeeds. Manage the unit
directly if you want to:
systemctl --user status bd-console.service
systemctl --user stop bd-console.service
systemctl --user restart bd-console.serviceOpt out with bd-console settings set persist false (or the
BD_CONSOLE_PERSIST=0 env var), which makes start use a plain detached
background process (pid-file tracked) instead. On non-Linux platforms, or
when systemctl --user isn't usable, persist defaults to false
automatically.
Global settings (host, port, token, persist) live in
~/.config/bd-console/config.json (override the directory with
BD_CONSOLE_CONFIG_DIR). Precedence, highest wins:
CLI flags (--port, --host) > env vars > config.json > built-in defaults
Built-in defaults: 0.0.0.0:4180, no token, persist auto-detected.
Inspect and edit with bd-console settings:
bd-console settings list # effective value + source (flag/env/config/default) per key
bd-console settings set host 0.0.0.0
bd-console settings set port 4180
bd-console settings set token a-shared-secret
bd-console settings unset token
bd-console settings set persist false
bd-console settings # interactive walkthrough of all four keysconfig.json looks like:
{
"host": "0.0.0.0",
"port": 4180,
"token": "a-shared-secret",
"persist": true
}All keys are optional; omit any you want left at its default.
BD_CONSOLE_HOST # overrides host
BD_CONSOLE_PORT # overrides port
BD_CONSOLE_TOKEN # overrides token
BD_CONSOLE_TRUSTED_HOSTS # optional comma-separated public/proxy hostnames
BD_CONSOLE_PERSIST=0|1 # overrides persist
BD_CONSOLE_CONFIG_DIR # relocates ~/.config/bd-console entirely
BD_CONSOLE_SCHED_INTERVAL # scheduler poll interval in ms (default 15000)
BD_CONSOLE_TERMIX_URL # overrides termix.baseUrl (see below)
BD_CONSOLE_TERMIX_TOKEN # overrides termix.token
BD_CONSOLE_TERMIX_HOST_ID # overrides termix.hostId
BD_CONSOLE_IDLE_DAYS # days of terminal silence before a session is
# marked "unattended" (default 7 — see below)On Linux, the session list also reports what each tmux session's process tree
is holding in resident memory, and the hub carries a host-level chip: how much
memory is still available, whether this machine has any swap, and how much
of the total the detected agent sessions account for. A machine with swap gets
slow when it fills up and a human notices; a machine without swap has no such
stage — the kernel's OOM killer simply picks the largest process, which on this
project's own host has twice ended a tmux session and everything running in it.
Amber/red chips carry the state in words (memory tight, 1.2 GB high), never
in color alone.
A session is marked unattended when nobody has attached to it and it has
printed nothing for BD_CONSOLE_IDLE_DAYS days (default 7), while its
processes are still accruing CPU time. That combination — total terminal
silence plus live work — is what distinguishes a forgotten agent quietly
spending API quota from a parked shell (no CPU) or a session you drive from
this console (which produces output). The tooltip always names the evidence.
Everything here degrades to absent — no /proc, no Linux, no tmux, no
feature — rather than to an error.
If you run Termix (a self-hosted web SSH / terminal manager), every tmux session row in the hub, the Terminal sessions view and Console 2.0 can carry a Termix link that opens that session in Termix's web terminal. Optional; nothing appears until you configure it.
Three settings, all under termix in config.json:
bd-console settings set termix.baseUrl https://termix.example.com
bd-console settings set termix.token tmx_<64 hex chars>
bd-console settings set termix.hostId 3termix.baseUrl— the address you browse Termix at. The Docker image fronts all of its internal services with one nginx on a single published port (8080 by default), so this is that one origin — never the internal3000xservice ports.termix.token— a Termix API key, created inside Termix under Admin Settings → API Keys (formattmx_+ 64 hex characters). It is used by exactly one thing: the Look up hosts button on bd-console's Settings page, which lists your Termix hosts so you can pick the righthostIdwithout hunting for it. It is never sent to your browser and never appears in a link, and bd-console makes no other request to Termix — not on startup, not on save, not on a poll.termix.hostId— which Termix host entry is this machine. bd-console cannot infer this. Without it the session links still work but only open Termix; with it they attach the session directly.
The link is an ordinary URL
(…/?view=terminal&hostId=<id>&tmuxSession=<name>) that your browser opens
in a new tab. Termix requires its own login session for it, so the first
click of a browsing session may land on Termix's sign-in page — the link's
tooltip says so.
Deep links are an internal Termix contract, not a documented API. They work as of Termix's current
main; a future Termix release could change them. Thebd-consoleside is one function (lib/termix.mjs) if it ever needs updating.
A registered workspace may still have a bd-console.json at its root, but
its job has shrunk: only docRoots is honored now. host, port, and
token keys in a per-project file are inert — those all come from the
global config.json / env / flags described above. See
bd-console.json.example.
{
"docRoots": ["docs", ".planning"]
}docRoots: limits and orders which markdown folders the Docs view shows for that project. Omit it to auto-discover every*.mdunder the workspace (skippingnode_modules,.git,.next,dist,.beads, and similar).
bd-console-init (or node scripts/init.mjs from a clone) is an optional
helper for onboarding a single repo: it confirms .beads/ exists,
refreshes .beads/issues.jsonl, registers the repo with the hub (the same
as running bd-console add), writes bd-console.json if one doesn't exist
yet, and can update AGENTS.md / CLAUDE.md with bd-console guidance.
bd-console-init --repo ~/code/my-project --apply-agent-docs
bd-console-init --repo ~/code/my-project --apply-agent-docs --create-missing-agent-docs--repo here is a bd-console-init flag only — it picks which repo to
initialize/register. It has no equivalent on bd-console/bd-console start
(see docs/upgrading.md if you're coming from the old
per-repo --repo server flag). (Linux users): add --install-service to
install and start the shared hub's systemd user unit as part of the same
step.
bd-console's first run on a TTY (bare bd-console, or bd-console start
before any config.json exists) asks where you'll access the dashboard
from, because the fallback default bind is 0.0.0.0 — not 127.0.0.1 —
and that shouldn't be applied silently:
- This machine / home LAN → bind
0.0.0.0, open writes recommended. This is the common case for a box you trust everyone on your LAN to reach. - VPS / internet-exposed → bind to a private or tailnet interface first
(the prompt lists your non-loopback IPv4 addresses, plus
127.0.0.1); a random write-gating token is generated only as a last resort if you choose to bind0.0.0.0anyway on a box like this.
If a box is ever exposed publicly, put an authenticating reverse proxy in front of it (e.g. Pangolin's auth) rather than relying on the token alone. The token gates write endpoints against casual/accidental use; it is not a substitute for real authentication on an internet-facing host.
Non-interactive first runs (no TTY — systemd, scripts, CI) skip the prompt,
apply the 0.0.0.0:4180/no-token defaults, and log one line saying so.
Change any of this later with bd-console settings.
Other invariants, unchanged from the original design:
- Writes go through
bdviaexecFilewith an args array — no shell interpolation, ever. - Issue IDs and labels are format-validated before every write.
- Browser writes must be same-origin JSON requests. Direct LAN hostnames,
interface addresses, and a locally terminating Pangolin/newt proxy work
without setup; unusual remote proxy topologies can add their public names
with
BD_CONSOLE_TRUSTED_HOSTS=name1,name2. - File/session creation and doc/formula reads and writes are confined to registered workspace roots using real-path checks, including symlinks.
- Sensitive host reads (settings, scheduler prompts, saved prompts, tmux inventory/previews, and usage details) are token-gated when an optional token is configured. They remain open in the intentional tokenless LAN mode.
- Tokens are accepted only through the
x-bd-tokenheader, never a query string. The browser UI stores an optional token inlocalStorageand sends that header automatically.
The scheduler queues a prompt to be typed into an already-running, named tmux session at a scheduled time — think "wake this agent up and give it its next instruction at 7am" or "resume this session after my laptop sleeps through the night."
What it does not do, by design:
- it never creates or attaches a tmux session for you — the session named in the job must already exist when the job fires
- it never runs a command like
claude -pdirectly; it only re-uses the same literaltmux send-keysinjection the tmux pane preview/UI uses, so the "prompt" always lands as literal keystrokes in an interactive session, never as something the scheduler interprets or executes itself
A job that fires against a session that no longer exists (or never existed)
ends up failed, not silently dropped — check GET /api/schedule for job
status (pending / sent / failed / cancelled) and error.
GET /api/tmux labels each session and pane with the agent CLI it detected
(agent: claude / codex / gemini / kimi / null) and whether that
pane can actually be prompted (promptable, plus a mode of interactive /
server / shell / unknown and a human reason). Detection reads tmux's
own fields first, then the pane process's real /proc argv and its children,
with the pane title as a last-resort tiebreak — pane_current_command alone
can't tell an interactive claude from a claude rc remote-control host, and
kimi-code looks identical whether it's a TUI or a web server.
Sessions in server mode (claude rc, codex app-server, a kimi web
server, …) are still listed — they're real work on the host — but their
prompt/delegate controls are disabled with the reason attached, and
POST /api/tmux/send answers 409 instead of typing into a process that
will never read it. That refusal only happens when the verdict came from the
process's own argv; add "force": true to the body to override it. Anything
less certain stays promptable, so detection can only ever add refusals it can
prove.
API surface, hub-level (not project-scoped):
GET /api/tmux list tmux sessions/panes (token-gated when configured)
(+ detected agent / mode / promptable)
GET /api/tmux/preview?session=&lines= scrollback preview (token-gated when configured)
POST /api/tmux/send type a prompt now: {session, text, force?}
GET /api/schedule list jobs (token-gated when configured)
POST /api/schedule create a job: {prompt, session, runAt}
POST /api/schedule/cancel cancel a still-pending job: {id}
The scheduler polls for due jobs every BD_CONSOLE_SCHED_INTERVAL
milliseconds (default 15000). It requires node:sqlite (bundled with Node
22+); if it's ever unavailable, the endpoints above return 501 instead of
erroring, and the rest of the hub keeps working normally. Likewise, if
tmux isn't installed on the host, /api/tmux reports
{ available: false, sessions: [] } rather than failing.
Six theme presets — Synergy, Default, Dracula, Nord, Gruvbox, Tokyo Night —
each with light, dark, and an "auto" mode that follows
prefers-color-scheme. Synergy is the default for first-time visitors
(a void-black/near-white neon green + electric purple theme, with Space
Grotesk/Manrope typography); anyone who has already picked a preset keeps
their existing choice untouched. Pick one from the theme switcher in the
top bar; your choice is remembered in localStorage and applied before
first paint (no flash of the wrong theme).
| Shortcut | Action |
|---|---|
/ |
Focus the search field (the omnibar, inside a project) |
i |
Open the New issue dialog |
Esc |
Close the open modal dialog or remove focus |
These are ignored while you're typing into a form field or text input.
Console 2.0's omnibar has its own shortcuts on top of these — see
docs/feature-map.md §5.
The dashboard is hash-routed (#/, #/p2/<projectId>, plus #/tmux,
#/schedule, #/settings and #/learn), so deep links work even served as
static files. The older #/p/<projectId> project view was retired; those
URLs now redirect to #/p2/<projectId>, so old bookmarks keep working.
Local checks:
npm run check
npm run smoke
npm_config_cache=/tmp/bd-console-npm-cache npm pack --dry-run-
npm run checkrunsnode --checkover every server, script, and frontend module. -
npm run smokespins up a temporary beads repo and a temporaryBD_CONSOLE_CONFIG_DIR, startsbd-consoleagainst them, and exercises the hub end to end: registry add/list, issues/docs/comments, inline edits, quick capture, rich issue creation + epics, the tmux API, the scheduler (create/list/cancel + a tick-driven failure case), daemonstartsupersede behavior, systemd unit-file generation,update --dry-run,settingsset/list/unset, and the non-TTY first-run defaults path. It never touches your real~/.config/bd-console, your real~/.claude/~/.codex/~/.kimi-code/~/.gemini, or a real systemd user session. -
The suite is split by domain under
scripts/smoke/, withscripts/smoke.mjsas the single entry point. Run one domain while you iterate on it:npm run smoke -- usage # or: node scripts/smoke.mjs usage node scripts/smoke.mjs docs formulas node scripts/smoke.mjs --list # the domain names
A plain
npm run smokeruns every domain except the opt-in ones, which--listmarks. -
node scripts/smoke.mjs browseris the one opt-in domain. It drives a real headless Chrome over the DevTools Protocol — using Node's built-in WebSocket client, so it adds no npm dependency — and asserts only the things Node cannot see: that no route widens past the viewport at 1280px or 390px, that the Detail slide-over opens on screen, traps focus, and never scrolls the shell (the bd-console-clb regression, checked across all three close paths), and that the panel actually receives taps rather than the content behind it. It is excluded from the default run because booting a browser costs ~19s against a suite that otherwise finishes in well under a minute; run it before shipping changes to layout,overflow,positionorz-index. With no Chrome installed it prints asmoke skip (...)line and exits cleanly. Point it at a specific binary withBD_CONSOLE_CHROME=/path/to/chrome. -
npm pack --dry-runconfirms the published tarball contains the expected files (the smoke suite is not published — onlyscripts/init.mjsships).
This repo also includes a no-build static docs site in site/.
npm run docs:serveFor deployment details, see docs/docs-site.md. If
you're upgrading from an older single-repo install of bd-console, see
docs/upgrading.md.
Comments are always read live via bd comments --json, so they never go
stale. The issue list comes from .beads/issues.jsonl; the server
auto-refreshes that export whenever it's missing or older than
.beads/last-touched, and the dashboard shows the current sync state in
the top bar.
If you mutate issues outside the dashboard — bd create, bd update,
bd close, bd dep, bd label, or similar — and want the change to show
up before the next auto-refresh, run:
bd export -o .beads/issues.jsonlQuick capture and rich issue creation both default new issues into the
triage label if you don't set one. Treat triage as an inbox, not a
final classification:
- Use
triagefor raw captured work that still needs sorting. - Add focused labels after review.
- For doc-originated ideas, use a
doc:<path>label so the source document stays recoverable, until doc-anchored capture becomes a first-class UI flow.
bd-console-init --apply-agent-docs inserts a short bd-console block into
AGENTS.md / CLAUDE.md covering how to start the hub, that
.beads/issues.jsonl should be refreshed after non-UI beads mutations, the
triage / doc:<path> conventions, and how to verify the dashboard is
reachable. This repo's own AGENTS.md and CLAUDE.md show the intended
shape.
no .beads/ found at <path>when runningbd-console addThe path (or nothing above it, walking up from cwd) has a.beads/directory. Runbd initthere first, or pointaddat the right path.Port <n> is already in use by a process that does not look like bd-consolestartrefuses to kill unknown processes on the configured port. Free it manually, or run with a different--port.startsays it started, but the dashboard doesn't load Runbd-console status— it checks real port reachability, not just whether a pid is alive. If it's not reachable, check the log (~/.config/bd-console/console.log, orjournalctl --user -u bd-console.serviceunder systemd supervision) —startalso tails this automatically on failure.- A project I registered doesn't show up / 404s
Confirm it's actually registered:
bd-console list. Re-add it withbd-console add <path>if not. - Issue list looks stale
Run
bd export -o .beads/issues.jsonlinside that project (see Export freshness); the server also auto-refreshes it on access when stale. - Comments show up but new issues or status changes don't Comments are always live; the issue list comes from the JSONL export.
- Docs list is noisy in a large repo
Set
docRootsin that project'sbd-console.json. - The scheduler / tmux API returns
501You're on Node <22 (node:sqliteisn't available). Upgrade Node — the rest of the hub still works without it. - A scheduled job shows status
failedCheck itserror— almost always "tmux session not found": the named session wasn't running when the job'srunAtarrived. The scheduler never creates sessions for you.